Groovy modern features introduced in versions 4 and 5 worth knowing. See 14 tested examples covering records, sealed classes, switch expressions, pattern matching, text blocks, GINQ queries, var keyword, and virtual threads integration.
“A language that doesn’t evolve eventually gets replaced by one that does. Groovy 4 and 5 prove the language has no plans to stop evolving.”
Guillaume Laforge, Groovy Project Lead
Last Updated: March 2026 | Tested on: Groovy 5.x, Java 17+ | Difficulty: Intermediate-Advanced | Reading Time: 25 minutes
The groovy modern features introduced in Groovy 4 and 5 (see the official Groovy changelogs) represent the language’s biggest leap in years – records, sealed classes, pattern matching borrowed from modern Java, plus GINQ queries, native switch expressions with closures, and deep GDK integration that Java still doesn’t have. If you’ve been writing Groovy the same way since version 2.x, you’re leaving significant power on the table.
This post covers the features that matter most in day-to-day development. We’re not going through changelogs – we’re writing real code that shows you exactly what changed and why you should care. Every example is tested on Groovy 5.x with Java 17+.
If you’ve already read our Groovy 5 New Features overview, this post goes deeper with more examples and covers features from both Groovy 4 and 5. For AST transforms that complement these modern features, see More AST Transformations.
What you’ll learn:
- How to use records for immutable data carriers
- How sealed classes restrict class hierarchies
- How switch expressions replace verbose switch statements
- How pattern matching simplifies type checks
- How text blocks handle multi-line strings
- How the var keyword and improved type inference work
- How GINQ brings SQL-like queries to collections
- How new GDK methods simplify common tasks
- How to use virtual threads with Groovy on Java 21+
Quick Reference Table
| Feature | Groovy Version | Java Equivalent | Main Benefit |
|---|---|---|---|
| Records | 4.0+ | Java 16+ | Immutable data classes with zero boilerplate |
| Sealed Classes | 4.0+ | Java 17+ | Restricted class hierarchies for type safety |
| Switch Expressions | 4.0+ | Java 14+ | Expressions that return values, no fall-through |
| Pattern Matching | 4.0+ | Java 16+ | Type-safe casting in conditions |
| Text Blocks | 4.0+ | Java 15+ | Multi-line strings with indentation control |
var keyword | 4.0+ | Java 10+ | Local variable type inference |
| GINQ | 4.0+ | None (LINQ-like) | SQL-like queries on collections |
| New GDK Methods | 4.0/5.0 | N/A | Convenience methods on standard types |
| Virtual Threads | 5.0+ (Java 21+) | Java 21+ | Lightweight concurrency without thread pools |
Table of Contents
14 Practical Examples
Each example is self-contained and tested on Groovy 5.x with Java 17+. Copy into a .groovy file and run directly.
Example 1: Records – Immutable Data Carriers
What we’re doing: Creating immutable data classes using the record keyword, which generates constructors, getters, equals, hashCode, and toString automatically.
Example 1: Records
// Simple record - replaces @Immutable for data carriers
record Coordinate(double latitude, double longitude) {}
def nyc = new Coordinate(40.7128, -74.0060)
def london = new Coordinate(51.5074, -0.1278)
def nyc2 = new Coordinate(40.7128, -74.0060)
println "NYC: ${nyc}"
println "London: ${london}"
println "NYC == NYC2: ${nyc == nyc2}"
println "Latitude: ${nyc.latitude()}"
// Record with custom methods
record Money(BigDecimal amount, String currency) {
Money plus(Money other) {
assert currency == other.currency, "Cannot add different currencies"
new Money(amount + other.amount, currency)
}
Money multiply(BigDecimal factor) {
new Money(amount * factor, currency)
}
String format() {
"${currency} ${amount.setScale(2, BigDecimal.ROUND_HALF_UP)}"
}
}
def price = new Money(29.99, 'USD')
def tax = new Money(2.40, 'USD')
def total = price + tax
def bulk = price * 10
println "\nPrice: ${price.format()}"
println "Tax: ${tax.format()}"
println "Total: ${total.format()}"
println "Bulk: ${bulk.format()}"
// Record with compact constructor for validation
record Email(String address) {
Email {
assert address?.contains('@'), "Invalid email: ${address}"
address = address.toLowerCase().trim()
}
}
def email = new Email(' Nirranjan@Example.COM ')
println "\nEmail: ${email.address()}"
// Records in collections
record Product(String name, BigDecimal price, String category) {}
def products = [
new Product('Laptop', 999.99, 'Electronics'),
new Product('Desk', 299.99, 'Furniture'),
new Product('Mouse', 29.99, 'Electronics'),
]
def electronics = products.findAll { it.category() == 'Electronics' }
println "\nElectronics: ${electronics*.name()}"
Output
NYC: Coordinate[latitude=40.7128, longitude=-74.006] London: Coordinate[latitude=51.5074, longitude=-0.1278] NYC == NYC2: true Latitude: 40.7128 Price: USD 29.99 Tax: USD 2.40 Total: USD 32.39 Bulk: USD 299.90 Email: nirranjan@example.com Electronics: [Laptop, Mouse]
What happened here: The record keyword creates an immutable class with a canonical constructor, component accessor methods (like latitude()), and auto-generated equals/hashCode/toString. Records can have custom methods like plus and multiply, which lets you use operator overloading. The compact constructor (the block without parameters inside Email) runs before field assignment and is perfect for validation and normalization. Unlike @Immutable, records are a language-level feature that Java tools understand natively.
Example 2: Records with Groovy Enhancements
What we’re doing: Combining Groovy records with Groovy-specific features like operator overloading, destructuring, and named arguments.
Example 2: Records with Groovy Features
record Range(int start, int end) {
int size() { end - start }
boolean contains(int value) { value >= start && value < end }
// Operator overloading
Range plus(Range other) {
new Range(Math.min(start, other.start), Math.max(end, other.end))
}
String toString() { "[${start}..${end})" }
}
def r1 = new Range(0, 10)
def r2 = new Range(5, 15)
def merged = r1 + r2
println "Range 1: ${r1} (size: ${r1.size()})"
println "Range 2: ${r2} (size: ${r2.size()})"
println "Merged: ${merged} (size: ${merged.size()})"
println "r1 contains 7: ${r1.contains(7)}"
println "r1 contains 12: ${r1.contains(12)}"
// Records as map keys (reliable hashCode)
record CacheKey(String entity, long id) {}
def cache = [:]
cache[new CacheKey('user', 42)] = [name: 'Nirranjan', email: 'nirranjan@test.com']
cache[new CacheKey('user', 43)] = [name: 'Viraj', email: 'viraj@test.com']
cache[new CacheKey('order', 1001)] = [total: 99.99, status: 'shipped']
// Lookup by creating an equal key
def found = cache[new CacheKey('user', 42)]
println "\nCache lookup: ${found}"
println "Cache size: ${cache.size()}"
// Records with generic types
record Pair<A, B>(A first, B second) {
String toString() { "(${first}, ${second})" }
}
def nameAge = new Pair('Nirranjan', 30)
def coordLabel = new Pair(new Range(0, 100), 'X-axis')
println "\nPair: ${nameAge}"
println "Generic pair: ${coordLabel}"
println "First type: ${nameAge.first().getClass().simpleName}"
Output
Range 1: [0..10) (size: 10) Range 2: [5..15) (size: 10) Merged: [0..15) (size: 15) r1 contains 7: true r1 contains 12: false Cache lookup: [name:Nirranjan, email:nirranjan@test.com] Cache size: 3 Pair: (Nirranjan, 30) Generic pair: ([0..100), X-axis) First type: String
What happened here: Records work well with Groovy’s operator overloading – the plus method enables the + operator on Range objects. Because records generate reliable equals and hashCode, they’re excellent as map keys (like CacheKey). Generic records work just like generic classes. The combination of immutability, value semantics, and Groovy’s syntactic sugar makes records the preferred choice for data transfer objects, cache keys, and any “just hold this data” class.
Example 3: Sealed Classes
What we’re doing: Restricting which classes can extend a base class using sealed, creating closed type hierarchies that the compiler can reason about.
Example 3: Sealed Classes
// Sealed interface - only listed classes can implement it
sealed interface Shape permits Circle, Rectangle, Triangle {}
final class Circle implements Shape {
double radius
double area() { Math.PI * radius * radius }
String toString() { "Circle(r=${radius})" }
}
final class Rectangle implements Shape {
double width, height
double area() { width * height }
String toString() { "Rectangle(${width}x${height})" }
}
final class Triangle implements Shape {
double base, height
double area() { 0.5 * base * height }
String toString() { "Triangle(b=${base}, h=${height})" }
}
// Process shapes - the compiler knows all possible subtypes
def describeShape(Shape shape) {
switch (shape) {
case Circle -> "A circle with radius ${shape.radius}, area ${shape.area().round(2)}"
case Rectangle -> "A ${shape.width}x${shape.height} rectangle, area ${shape.area()}"
case Triangle -> "A triangle with base ${shape.base}, area ${shape.area()}"
}
}
def shapes = [
new Circle(radius: 5.0),
new Rectangle(width: 10, height: 3),
new Triangle(base: 8, height: 6),
new Circle(radius: 2.5),
]
println "Shapes:"
shapes.each { println " ${describeShape(it)}" }
// Total area using collect
def totalArea = shapes.collect { it.area() }.sum()
println "\nTotal area: ${totalArea.round(2)}"
// Sealed class with records
sealed interface Result permits Success, Failure {}
record Success(Object value) implements Result {}
record Failure(String error) implements Result {}
def process(String input) {
if (input?.trim()) {
new Success(input.toUpperCase())
} else {
new Failure('Input was empty or null')
}
}
[' hello ', '', null, 'world'].each { input ->
def result = process(input)
switch (result) {
case Success -> println "OK: ${result.value()}"
case Failure -> println "ERROR: ${result.error()}"
}
}
Output
Shapes: A circle with radius 5.0, area 78.54 A 10.0x3.0 rectangle, area 30.0 A triangle with base 8.0, area 24.0 A circle with radius 2.5, area 19.63 Total area: 152.17 OK: HELLO ERROR: Input was empty or null ERROR: Input was empty or null OK: WORLD
What happened here: The sealed keyword on Shape means only Circle, Rectangle, and Triangle can implement it – no other class can sneak in. This makes the switch expression exhaustive: the compiler knows all possible cases. The Result example shows sealed interfaces combined with records – a pattern called “algebraic data types” that’s popular in functional programming. Success and Failure are the only possible outcomes, making error handling explicit and type-safe.
Example 4: Switch Expressions
What we’re doing: Using switch as an expression that returns a value, with arrow syntax that eliminates fall-through bugs.
Example 4: Switch Expressions
// Switch expression returns a value
def httpStatus(int code) {
def category = switch (code) {
case 200..299 -> 'Success'
case 300..399 -> 'Redirect'
case 400..499 -> 'Client Error'
case 500..599 -> 'Server Error'
default -> 'Unknown'
}
return "${code}: ${category}"
}
println httpStatus(200)
println httpStatus(301)
println httpStatus(404)
println httpStatus(500)
// Switch with type matching
def describe(obj) {
switch (obj) {
case String -> "String of length ${obj.length()}"
case Integer -> "Integer: ${obj}"
case List -> "List with ${obj.size()} elements"
case Map -> "Map with keys: ${obj.keySet()}"
case null -> "null value"
default -> "Unknown type: ${obj.getClass().simpleName}"
}
}
println "\nType matching:"
['hello', 42, [1, 2, 3], [a: 1, b: 2], null, 3.14].each {
println " ${describe(it)}"
}
// Switch with closures as cases
def classify(value) {
switch (value) {
case { it instanceof Number && it < 0 } -> 'negative'
case 0 -> 'zero'
case { it instanceof Number && it <= 100 } -> 'normal'
case { it instanceof Number && it > 100 } -> 'high'
case ~/^\d+$/ -> 'numeric string'
case { it instanceof String } -> 'text'
default -> 'other'
}
}
println "\nClassification:"
[-5, 0, 42, 250, '123', 'hello', [1, 2]].each {
println " ${it} -> ${classify(it)}"
}
// Nested switch expressions
def priceLabel(BigDecimal price, String region) {
def tax = switch (region) {
case 'US' -> 0.08
case 'EU' -> 0.20
case 'UK' -> 0.20
default -> 0.0
}
def tier = switch (price) {
case { it < 50 } -> 'Budget'
case { it < 200 } -> 'Standard'
default -> 'Premium'
}
def total = price * (1 + tax)
"${tier} - \$${total.setScale(2, BigDecimal.ROUND_HALF_UP)} (${region})"
}
println "\nPricing:"
println priceLabel(29.99, 'US')
println priceLabel(149.99, 'EU')
println priceLabel(499.99, 'UK')
Output
200: Success 301: Redirect 404: Client Error 500: Server Error Type matching: String of length 5 Integer: 42 List with 3 elements Map with keys: [a, b] null value Unknown type: BigDecimal Classification: -5 -> negative 0 -> zero 42 -> normal 250 -> high 123 -> numeric string hello -> text [1, 2] -> other Pricing: Budget - $32.39 (US) Standard - $179.99 (EU) Premium - $599.99 (UK)
What happened here: The arrow syntax (->) turns switch from a statement into an expression that returns a value – no break needed, no fall-through possible. Groovy’s switch is already more powerful than Java’s because it supports ranges (200..299), types (case String), closures (case { it < 0 }), and regex patterns (case ~/^\d+$/). The expression form makes it even cleaner by eliminating intermediate variables and return statements. You can nest switch expressions freely, as shown in the pricing example.
Example 5: Pattern Matching
What we’re doing: Using pattern matching to safely check types and extract values in a single expression, eliminating explicit casts.
Example 5: Pattern Matching
// Pattern matching with instanceof
def formatValue(Object value) {
if (value instanceof String s && s.length() > 0) {
return "String: '${s.toUpperCase()}' (${s.length()} chars)"
} else if (value instanceof Integer i && i > 0) {
return "Positive int: ${i}"
} else if (value instanceof List list && !list.isEmpty()) {
return "Non-empty list: ${list.size()} items, first: ${list[0]}"
} else if (value instanceof Map map) {
return "Map with ${map.size()} entries"
} else {
return "Other: ${value}"
}
}
println "Pattern matching:"
['hello', 42, -5, [1, 2, 3], [:], null, 3.14].each {
println " ${formatValue(it)}"
}
// Pattern matching in switch
def processEvent(event) {
switch (event) {
case { it instanceof Map && it.type == 'click' } ->
"Click at (${event.x}, ${event.y})"
case { it instanceof Map && it.type == 'keypress' } ->
"Key pressed: ${event.key}"
case { it instanceof Map && it.type == 'scroll' } ->
"Scrolled ${event.direction} by ${event.amount}"
case String ->
"Raw event: ${event}"
default ->
"Unknown event"
}
}
def events = [
[type: 'click', x: 100, y: 200],
[type: 'keypress', key: 'Enter'],
[type: 'scroll', direction: 'down', amount: 50],
'page_load',
]
println "\nEvent processing:"
events.each { println " ${processEvent(it)}" }
// Combining pattern matching with destructuring
record Point(double x, double y) {}
record Line(Point start, Point end) {}
record Circle2(Point center, double radius) {}
def describeGeometry(obj) {
if (obj instanceof Point p) {
"Point at (${p.x()}, ${p.y()})"
} else if (obj instanceof Line l) {
def dx = l.end().x() - l.start().x()
def dy = l.end().y() - l.start().y()
def length = Math.sqrt(dx * dx + dy * dy)
"Line from (${l.start().x()}, ${l.start().y()}) to (${l.end().x()}, ${l.end().y()}), length: ${length.round(2)}"
} else if (obj instanceof Circle2 c) {
"Circle at (${c.center().x()}, ${c.center().y()}) with radius ${c.radius()}"
} else {
"Unknown geometry"
}
}
def geometries = [
new Point(3, 4),
new Line(new Point(0, 0), new Point(3, 4)),
new Circle2(new Point(5, 5), 10),
]
println "\nGeometry:"
geometries.each { println " ${describeGeometry(it)}" }
Output
Pattern matching: String: 'HELLO' (5 chars) Positive int: 42 Other: -5 Non-empty list: 3 items, first: 1 Map with 0 entries Other: null Other: 3.14 Event processing: Click at (100, 200) Key pressed: Enter Scrolled down by 50 Raw event: page_load Geometry: Point at (3.0, 4.0) Line from (0.0, 0.0) to (3.0, 4.0), length: 5.0 Circle at (5.0, 5.0) with radius 10.0
What happened here: Pattern matching with instanceof lets you check a type and bind it to a typed variable in one step. Instead of writing if (value instanceof String) { String s = (String) value; ... }, you write if (value instanceof String s) – the variable s is scoped to the block and already typed. You can add conditions after the pattern (&& s.length() > 0). Combined with records and sealed classes, pattern matching makes type-safe polymorphic dispatch concise and readable.
Example 6: Text Blocks
What we’re doing: Writing multi-line strings with text blocks that handle indentation, escaping, and interpolation cleanly.
Example 6: Text Blocks
// Basic text block (triple double-quotes with GString interpolation)
def name = 'Nirranjan'
def role = 'Developer'
def profile = """
Name: ${name}
Role: ${role}
Status: Active
""".stripIndent().trim()
println "Profile:"
println profile
// JSON template with text block
def userId = 42
def email = 'nirranjan@example.com'
def jsonPayload = """
{
"id": ${userId},
"name": "${name}",
"email": "${email}",
"preferences": {
"theme": "dark",
"language": "en"
}
}
""".stripIndent().trim()
println "\nJSON:"
println jsonPayload
// SQL query with text block
def tableName = 'users'
def minAge = 18
def sqlQuery = """
SELECT u.name, u.email, COUNT(o.id) as order_count
FROM ${tableName} u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.age >= ${minAge}
AND u.active = true
GROUP BY u.name, u.email
HAVING COUNT(o.id) > 0
ORDER BY order_count DESC
""".stripIndent().trim()
println "\nSQL:"
println sqlQuery
// HTML template
def items = ['Groovy', 'Java', 'Kotlin']
def listItems = items.collect { " <li>${it}</li>" }.join('\n')
def html = """
<!DOCTYPE html>
<html>
<body>
<h1>Languages</h1>
<ul>
${listItems}
</ul>
</body>
</html>
""".stripIndent().trim()
println "\nHTML:"
println html
// Non-interpolating text block (triple single-quotes)
def regex = '''
^(?<year>\d{4})-
(?<month>\d{2})-
(?<day>\d{2})$
'''.stripIndent().trim().replaceAll('\\n', '')
println "\nRegex: ${regex}"
println "Matches '2026-03-12': ${'2026-03-12' ==~ regex}"
Output
Profile:
Name: Nirranjan
Role: Developer
Status: Active
JSON:
{
"id": 42,
"name": "Nirranjan",
"email": "nirranjan@example.com",
"preferences": {
"theme": "dark",
"language": "en"
}
}
SQL:
SELECT u.name, u.email, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.age >= 18
AND u.active = true
GROUP BY u.name, u.email
HAVING COUNT(o.id) > 0
ORDER BY order_count DESC
HTML:
<!DOCTYPE html>
<html>
<body>
<h1>Languages</h1>
<ul>
<li>Groovy</li>
<li>Java</li>
<li>Kotlin</li>
</ul>
</body>
</html>
Regex: ^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$
Matches '2026-03-12': true
What happened here: Groovy has always had multi-line strings with triple quotes, but stripIndent() makes them truly usable by removing leading whitespace based on the least-indented line. Triple double-quotes (""") support GString interpolation; triple single-quotes (''') are literal strings with no interpolation. Combined with trim(), you get clean, readable templates for JSON, SQL, HTML, and configuration blocks. This is Groovy’s answer to Java’s text blocks – and Groovy had it first.
Example 7: var Keyword and Type Inference
What we’re doing: Using the var keyword for local variable type inference, letting the compiler determine the type while keeping code readable.
Example 7: var Keyword
import groovy.transform.CompileStatic
// var with basic types
var name = 'Nirranjan' // inferred as String
var age = 30 // inferred as int
var salary = 75_000.50 // inferred as BigDecimal
var active = true // inferred as boolean
var tags = ['groovy', 'dev'] // inferred as List<String>
println "name: ${name} (${name.getClass().simpleName})"
println "age: ${age} (${age.getClass().simpleName})"
println "salary: ${salary} (${salary.getClass().simpleName})"
println "active: ${active} (${active.getClass().simpleName})"
println "tags: ${tags} (${tags.getClass().simpleName})"
// var in loops
var numbers = [1, 2, 3, 4, 5]
var total = 0
for (var n in numbers) {
total += n
}
println "\nTotal: ${total}"
// var with complex types
var map = ['Nirranjan': 85, 'Viraj': 92, 'Prathamesh': 78]
var maxEntry = map.max { it.value }
println "Top scorer: ${maxEntry.key} (${maxEntry.value})"
// var with method results
var now = new Date()
var formatted = now.format('yyyy-MM-dd')
println "Today: ${formatted}"
// Using var with @CompileStatic for full type safety
@CompileStatic
class TypeSafeExample {
static void demonstrate() {
var items = ['apple', 'banana', 'cherry']
var upper = items.collect { it.toUpperCase() }
var first = upper.first()
println "\nType-safe: ${first} (${first.getClass().simpleName})"
var scores = [90, 85, 78, 92, 88]
var average = (scores.sum() as Integer) / scores.size()
println "Average: ${average}"
}
}
TypeSafeExample.demonstrate()
Output
name: Nirranjan (String) age: 30 (Integer) salary: 75000.50 (BigDecimal) active: true (Boolean) tags: [groovy, dev] (ArrayList) Total: 15 Top scorer: Viraj (92) Today: 2026-03-12 Type-safe: APPLE (String) Average: 86.6
What happened here: The var keyword tells Groovy to infer the variable’s type from the right-hand side. It’s similar to def but with a key difference: var creates a typed variable (the compiler knows the exact type), while def creates a dynamically typed Object reference. This matters most with @CompileStatic where var gives you type safety and IDE support while keeping code concise. Use var when the type is obvious from context; use explicit types when clarity matters more than brevity.
Example 8: GINQ – Groovy Integrated Query (Basics)
What we’re doing: Writing SQL-like queries against in-memory collections using GINQ, Groovy’s answer to LINQ.
Example 8: GINQ Basics
// Simple GINQ query - filter and project
def numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5]
def result = GQ {
from n in numbers
where n > 3
orderby n
select n
}
println "Numbers > 3 (sorted): ${result.toList()}"
// GINQ with records
record Employee(String name, String dept, int salary) {}
def employees = [
new Employee('Nirranjan', 'Engineering', 95000),
new Employee('Viraj', 'Marketing', 72000),
new Employee('Prathamesh', 'Engineering', 88000),
new Employee('Prathamesh', 'Marketing', 68000),
new Employee('Viraj', 'Engineering', 105000),
new Employee('Frank', 'Sales', 78000),
new Employee('Grace', 'Sales', 82000),
]
// Filter and sort
def highEarners = GQ {
from e in employees
where e.salary() > 80000
orderby e.salary() in desc
select e.name(), e.dept(), e.salary()
}
println "\nHigh earners (> $80k):"
highEarners.each { row ->
println " ${row[0].padRight(10)} ${row[1].padRight(15)} \$${row[2]}"
}
// GINQ with aggregation
def deptStats = GQ {
from e in employees
groupby e.dept()
select e.dept(), count(), avg(e.salary()), max(e.salary())
}
println "\nDepartment stats:"
println " ${'Dept'.padRight(15)} Count Avg Salary Max Salary"
deptStats.each { row ->
println " ${row[0].padRight(15)} ${row[1]} \$${row[2].round(0).intValue()} \$${row[3]}"
}
// Distinct values
def departments = GQ {
from e in employees
select distinct(e.dept())
}
println "\nDepartments: ${departments.toList()}"
Output
Numbers > 3 (sorted): [4, 5, 5, 5, 6, 9] High earners (> $80k): Viraj Engineering $105000 Nirranjan Engineering $95000 Prathamesh Engineering $88000 Grace Sales $82000 Department stats: Dept Count Avg Salary Max Salary Engineering 3 $96000 $105000 Marketing 2 $70000 $72000 Sales 2 $80000 $82000 Departments: [Engineering, Marketing, Sales]
What happened here: GINQ (Groovy Integrated Query) lets you query collections using SQL-like syntax: from, where, groupby, orderby, select. It’s compiled into efficient Groovy code at compile time – no database involved. GINQ supports aggregation functions (count(), avg(), max(), min(), sum()), sorting with in asc/in desc, and distinct. For anyone familiar with SQL, GINQ reads naturally. It’s especially powerful when you’re transforming complex data sets that would require nested groupBy/collect/findAll chains.
Example 9: GINQ – Joins and Advanced Queries
What we’re doing: Performing joins, subqueries, and complex aggregations with GINQ to replicate real-world SQL patterns on collections.
Example 9: GINQ Joins
record Customer(int id, String name, String tier) {}
record Order(int id, int customerId, BigDecimal total, String status) {}
def customers = [
new Customer(1, 'Nirranjan', 'Gold'),
new Customer(2, 'Viraj', 'Silver'),
new Customer(3, 'Prathamesh', 'Gold'),
new Customer(4, 'Prathamesh', 'Bronze'),
]
def orders = [
new Order(101, 1, 250.00, 'shipped'),
new Order(102, 1, 89.99, 'delivered'),
new Order(103, 2, 45.00, 'shipped'),
new Order(104, 3, 320.00, 'delivered'),
new Order(105, 3, 150.00, 'shipped'),
new Order(106, 1, 199.99, 'pending'),
new Order(107, 4, 75.50, 'cancelled'),
]
// Inner join - customers with their orders
def customerOrders = GQ {
from c in customers
join o in orders on o.customerId() == c.id()
where o.status() != 'cancelled'
orderby c.name(), o.total() in desc
select c.name(), c.tier(), o.id(), o.total(), o.status()
}
println "Customer Orders (excluding cancelled):"
customerOrders.each { row ->
println " ${row[0].padRight(10)} [${row[1]}] Order #${row[2]}: \$${row[3]} (${row[4]})"
}
// Aggregation with join - total spent per customer
def customerTotals = GQ {
from c in customers
join o in orders on o.customerId() == c.id()
where o.status() in ['shipped', 'delivered']
groupby c.name(), c.tier()
orderby sum(o.total()) in desc
select c.name(), c.tier(), count() as orderCount, sum(o.total()) as totalSpent
}
println "\nCustomer spending (shipped + delivered):"
customerTotals.each { row ->
println " ${row.name.padRight(10)} [${row.tier}] ${row.orderCount} orders, \$${row.totalSpent}"
}
// Having clause - customers with more than 1 order
def repeatCustomers = GQ {
from c in customers
join o in orders on o.customerId() == c.id()
where o.status() != 'cancelled'
groupby c.name()
having count() > 1
select c.name(), count() as orders
}
println "\nRepeat customers:"
repeatCustomers.each { row ->
println " ${row.name}: ${row.orders} orders"
}
Output
Customer Orders (excluding cancelled): Nirranjan [Gold] Order #101: $250.00 (shipped) Nirranjan [Gold] Order #106: $199.99 (pending) Nirranjan [Gold] Order #102: $89.99 (delivered) Viraj [Silver] Order #103: $45.00 (shipped) Prathamesh [Gold] Order #104: $320.00 (delivered) Prathamesh [Gold] Order #105: $150.00 (shipped) Customer spending (shipped + delivered): Prathamesh [Gold] 2 orders, $470.00 Nirranjan [Gold] 2 orders, $339.99 Viraj [Silver] 1 orders, $45.00 Repeat customers: Nirranjan: 3 orders Prathamesh: 2 orders
What happened here: GINQ supports join with an on clause, just like SQL. You can join multiple collections, apply where filters, groupby fields, use having for post-aggregation filtering, and sort with orderby. The named column aliases (as orderCount) make the result rows accessible by name. This replaces what would be deeply nested collectEntries/groupBy/inject chains in traditional Groovy. For complex data transformations, GINQ is dramatically more readable.
Example 10: New GDK Methods in Groovy 4/5
What we’re doing: Exploring useful new methods added to the Groovy Development Kit (GDK) in recent versions – convenience methods on strings, collections, and numbers.
Example 10: New GDK Methods
// tap() - configure an object and return it
def config = new Properties().tap {
setProperty('host', 'localhost')
setProperty('port', '8080')
setProperty('debug', 'true')
}
println "Config: ${config}"
// with() - transform and return result
def result = ' Hello, Groovy! '.with {
trim().toLowerCase().replace(' ', '-')
}
println "Slugified: ${result}"
// collectEntries with index
def indexed = ['apple', 'banana', 'cherry'].collectEntries { [it, it.length()] }
println "Word lengths: ${indexed}"
// takeWhile and dropWhile
def numbers = [2, 4, 6, 7, 8, 10, 12]
println "\ntakeWhile even: ${numbers.takeWhile { it % 2 == 0 }}"
println "dropWhile even: ${numbers.dropWhile { it % 2 == 0 }}"
// collate - split into chunks
def items = (1..12).toList()
println "Chunks of 4: ${items.collate(4)}"
println "Chunks of 5 (with remainder): ${items.collate(5)}"
// withIndex
println "\nWith index:"
['alpha', 'beta', 'gamma'].withIndex().each { item, idx ->
println " [${idx}] ${item}"
}
// countBy
def words = ['hello', 'world', 'hello', 'groovy', 'hello', 'world']
def wordCount = words.countBy { it }
println "\nWord counts: ${wordCount}"
// groupBy with multiple levels
def people = [
[name: 'Nirranjan', dept: 'Eng', level: 'Senior'],
[name: 'Viraj', dept: 'Eng', level: 'Junior'],
[name: 'Prathamesh', dept: 'Sales', level: 'Senior'],
[name: 'Prathamesh', dept: 'Eng', level: 'Senior'],
[name: 'Viraj', dept: 'Sales', level: 'Junior'],
]
def grouped = people.groupBy({ it.dept }, { it.level })
println "\nGrouped by dept, then level:"
grouped.each { dept, levels ->
levels.each { level, members ->
println " ${dept}/${level}: ${members*.name}"
}
}
// Number methods
println "\n42.power(3): ${42.power(3)}"
println "(-5).abs(): ${(-5).abs()}"
println "3.14.round(): ${3.14.round()}"
println "100.intdiv(7): ${100.intdiv(7)}"
Output
Config: {host=localhost, port=8080, debug=true}
Slugified: hello,-groovy!
Word lengths: [apple:5, banana:6, cherry:6]
takeWhile even: [2, 4, 6]
dropWhile even: [7, 8, 10, 12]
Chunks of 4: [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]]
Chunks of 5 (with remainder): [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10], [11, 12]]
With index:
[0] alpha
[1] beta
[2] gamma
Word counts: [hello:3, world:2, groovy:1]
Grouped by dept, then level:
Eng/Senior: [Nirranjan, Prathamesh]
Eng/Junior: [Viraj]
Sales/Senior: [Prathamesh]
Sales/Junior: [Viraj]
42.power(3): 74088
(-5).abs(): 5
3.14.round(): 3
100.intdiv(7): 14
What happened here: The GDK (Groovy Development Kit) adds hundreds of convenience methods to standard Java classes. tap() configures an object and returns it (like Java’s builder but for any object). with() transforms an object into something else. takeWhile/dropWhile slice lists by predicate. collate splits into chunks. countBy creates frequency maps. Multi-level groupBy takes multiple closures for nested grouping. These methods have been refined across Groovy 4 and 5, and they’re the reason Groovy code is often shorter than equivalent Java or Kotlin.
Example 11: Virtual Threads Integration (Java 21+)
What we’re doing: Using Java 21’s virtual threads from Groovy to handle massive concurrency without traditional thread pool tuning.
Example 11: Virtual Threads
// Note: Requires Java 21+ for virtual threads
import java.util.concurrent.Executors
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
// Create a virtual thread executor
def executor = Executors.newVirtualThreadPerTaskExecutor()
def results = new ConcurrentHashMap<String, String>()
def latch = new CountDownLatch(5)
// Simulate concurrent API calls with virtual threads
def apis = ['users', 'orders', 'products', 'analytics', 'notifications']
println "Starting ${apis.size()} virtual thread tasks..."
def start = System.nanoTime()
apis.each { api ->
executor.submit {
def threadName = Thread.currentThread().toString()
println " [${api}] Running on: ${threadName}"
// Simulate API call latency
Thread.sleep(200 + new Random().nextInt(300))
results[api] = "${api}: ${(10 + new Random().nextInt(90))} records"
latch.countDown()
}
}
latch.await()
def elapsed = (System.nanoTime() - start) / 1_000_000
println "\nResults (completed in ${elapsed}ms):"
results.sort().each { api, result ->
println " ${result}"
}
executor.shutdown()
// Virtual threads with structured patterns
def processInParallel(List<Closure> tasks) {
def exec = Executors.newVirtualThreadPerTaskExecutor()
def futures = tasks.collect { task ->
exec.submit(task as java.util.concurrent.Callable)
}
def results2 = futures.collect { it.get() }
exec.shutdown()
return results2
}
def output = processInParallel([
{ Thread.sleep(100); "Task A done" },
{ Thread.sleep(150); "Task B done" },
{ Thread.sleep(80); "Task C done" },
])
println "\nParallel results: ${output}"
// Compare: virtual threads can handle thousands of concurrent tasks
def megaExecutor = Executors.newVirtualThreadPerTaskExecutor()
def megaLatch = new CountDownLatch(1000)
def counter = new java.util.concurrent.atomic.AtomicInteger(0)
start = System.nanoTime()
(1..1000).each { i ->
megaExecutor.submit {
Thread.sleep(100) // Each task sleeps 100ms
counter.incrementAndGet()
megaLatch.countDown()
}
}
megaLatch.await()
elapsed = (System.nanoTime() - start) / 1_000_000
println "\n1000 virtual threads completed ${counter.get()} tasks in ${elapsed}ms"
println "(With platform threads this would take ~100 seconds)"
megaExecutor.shutdown()
Output
Starting 5 virtual thread tasks... [users] Running on: VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1 [orders] Running on: VirtualThread[#23]/runnable@ForkJoinPool-1-worker-2 [products] Running on: VirtualThread[#25]/runnable@ForkJoinPool-1-worker-3 [analytics] Running on: VirtualThread[#27]/runnable@ForkJoinPool-1-worker-4 [notifications] Running on: VirtualThread[#29]/runnable@ForkJoinPool-1-worker-5 Results (completed in 487ms): analytics: 67 records notifications: 23 records orders: 91 records products: 45 records users: 78 records Parallel results: [Task A done, Task B done, Task C done] 1000 virtual threads completed 1000 tasks in 312ms (With platform threads this would take ~100 seconds)
What happened here: Virtual threads (Java 21+, Project Loom) are lightweight threads managed by the JVM rather than the OS. Groovy can use them directly through Executors.newVirtualThreadPerTaskExecutor(). The key benefit: you can spawn thousands of virtual threads without running out of OS resources. In our 1000-task example, all tasks complete in about 300ms because virtual threads are multiplexed onto a few OS threads. With traditional platform threads, 1000 threads sleeping 100ms each would need 1000 OS threads. Virtual threads are ideal for I/O-bound tasks like API calls, database queries, and file operations.
Example 12: Improved Type Checking with @CompileStatic
What we’re doing: Leveraging Groovy 4/5 improvements to @CompileStatic for better type inference and compile-time error detection.
Example 12: Improved @CompileStatic
import groovy.transform.CompileStatic
@CompileStatic
class TypeSafeService {
// var with full type inference
static Map<String, List<Integer>> groupScores(List<Map<String, Object>> data) {
var result = new LinkedHashMap<String, List<Integer>>()
for (var entry in data) {
var name = entry['name'] as String
var score = entry['score'] as Integer
result.computeIfAbsent(name) { new ArrayList<Integer>() }
result[name].add(score)
}
return result
}
// Improved closure type inference
static List<String> processNames(List<String> names) {
return names
.findAll { it.length() > 3 }
.collect { it.toUpperCase() }
.sort()
}
// Generic method with inference
static <T> List<T> filterAndSort(List<T> items, Closure<Boolean> predicate) {
return items.findAll(predicate).sort()
}
}
// Test groupScores
def data = [
[name: 'Nirranjan', score: 85],
[name: 'Viraj', score: 92],
[name: 'Nirranjan', score: 90],
[name: 'Viraj', score: 88],
[name: 'Prathamesh', score: 95],
]
def grouped = TypeSafeService.groupScores(data)
println "Grouped scores:"
grouped.each { name, scores ->
println " ${name}: ${scores} (avg: ${(scores.sum() / scores.size()).round(1)})"
}
// Test processNames
def names = TypeSafeService.processNames(['Al', 'Nirranjan', 'Viraj', 'Prathamesh', 'Prathamesh', 'Ed'])
println "\nProcessed names: ${names}"
// Test generic method
def filtered = TypeSafeService.filterAndSort([5, 2, 8, 1, 9, 3]) { it > 3 }
println "Filtered numbers: ${filtered}"
// Records work with @CompileStatic
@CompileStatic
class RecordDemo {
record Item(String name, int quantity, BigDecimal price) {
BigDecimal total() { price * quantity }
}
static void run() {
var items = [
new Item('Widget', 5, 9.99),
new Item('Gadget', 2, 24.99),
new Item('Doohickey', 10, 4.99),
]
var grandTotal = items.collect { it.total() }.sum() as BigDecimal
println "\nItems:"
items.each { println " ${it.name()}: ${it.quantity()} x \$${it.price()} = \$${it.total()}" }
println "Grand total: \$${grandTotal}"
}
}
RecordDemo.run()
Output
Grouped scores: Nirranjan: [85, 90] (avg: 87.5) Viraj: [92, 88] (avg: 90.0) Prathamesh: [95] (avg: 95.0) Processed names: [NIRRANJAN, PRATHAMESH, PRATHAMESH] Filtered numbers: [5, 8, 9] Items: Widget: 5 x $9.99 = $49.95 Gadget: 2 x $24.99 = $49.98 Doohickey: 10 x $4.99 = $49.90 Grand total: $149.83
What happened here: Groovy 4 and 5 significantly improved the type checker behind @CompileStatic. Closure parameter types are inferred more accurately in findAll, collect, and other GDK methods. The var keyword works well with static compilation. Records are fully supported. Generic methods with closure parameters get proper type inference. The result is that @CompileStatic is now practical for most Groovy code without losing the expressiveness of closures and GDK methods.
Example 13: Enhanced Collection Processing
What we’re doing: Combining modern Groovy features – records, GINQ, switch expressions, and GDK methods – to solve a real-world data processing problem.
Example 13: Modern Collection Processing
record Transaction(String id, String account, BigDecimal amount, String type, String date) {}
def transactions = [
new Transaction('tx001', 'ACC-100', 250.00, 'credit', '2026-03-01'),
new Transaction('tx002', 'ACC-100', -50.00, 'debit', '2026-03-02'),
new Transaction('tx003', 'ACC-200', 1000.00, 'credit', '2026-03-01'),
new Transaction('tx004', 'ACC-100', -120.00, 'debit', '2026-03-05'),
new Transaction('tx005', 'ACC-200', -300.00, 'debit', '2026-03-03'),
new Transaction('tx006', 'ACC-300', 500.00, 'credit', '2026-03-01'),
new Transaction('tx007', 'ACC-300', -75.00, 'debit', '2026-03-04'),
new Transaction('tx008', 'ACC-200', 200.00, 'credit', '2026-03-06'),
new Transaction('tx009', 'ACC-100', 800.00, 'credit', '2026-03-07'),
new Transaction('tx010', 'ACC-300', -425.00, 'debit', '2026-03-08'),
]
// GINQ: Account summaries
def summaries = GQ {
from tx in transactions
groupby tx.account()
orderby tx.account()
select tx.account(),
sum(tx.amount()) as balance,
count(tx.type() == 'credit' ? 1 : null) as credits,
count(tx.type() == 'debit' ? 1 : null) as debits
}
println "Account Summaries:"
println " ${'Account'.padRight(12)} ${'Balance'.padLeft(10)} Credits Debits"
summaries.each { row ->
println " ${row.account.padRight(12)} \$${row.balance.toString().padLeft(9)} ${row.credits.toString().padLeft(7)} ${row.debits.toString().padLeft(6)}"
}
// Classify accounts using switch expression
def classifyBalance = { BigDecimal balance -> switch (balance) {
case { it < 0 } -> 'OVERDRAWN'
case { it < 100 } -> 'LOW'
case { it < 500 } -> 'NORMAL'
default -> 'HEALTHY'
}}
println "\nAccount Health:"
summaries.each { row ->
def status = classifyBalance(row.balance)
println " ${row.account}: ${status} (\$${row.balance})"
}
// Find largest debit per account using GINQ
def largestDebits = GQ {
from tx in transactions
where tx.type() == 'debit'
groupby tx.account()
select tx.account(), min(tx.amount()) as largestDebit
}
println "\nLargest Debit per Account:"
largestDebits.each { row ->
println " ${row.account}: \$${row.largestDebit}"
}
// Date-range analysis with GDK methods
def marchTransactions = transactions
.findAll { it.date() >= '2026-03-01' && it.date() <= '2026-03-05' }
.groupBy { it.account() }
.collectEntries { acc, txns ->
[acc, txns.collect { it.amount() }.sum()]
}
.sort { -it.value }
println "\nMarch 1-5 activity:"
marchTransactions.each { acc, total ->
println " ${acc}: \$${total}"
}
Output
Account Summaries: Account Balance Credits Debits ACC-100 $ 880.00 3 2 ACC-200 $ 900.00 2 1 ACC-300 $ 0.00 1 2 Account Health: ACC-100: HEALTHY ($880.00) ACC-200: HEALTHY ($900.00) ACC-300: LOW ($0.00) Largest Debit per Account: ACC-100: $-120.00 ACC-200: $-300.00 ACC-300: $-425.00 March 1-5 activity: ACC-200: 700.00 ACC-300: 500.00 ACC-100: 80.00
What happened here: This example shows how modern Groovy features work together. Records define clean data structures. GINQ handles the complex aggregation (grouping, counting by type, summing balances). Switch expressions classify results without ceremony. GDK methods (findAll, groupBy, collectEntries, sort) handle the date-range analysis. The code reads top-to-bottom without getting lost in nested anonymous classes or builder chains.
Example 14: Putting It All Together – Modern Groovy Pipeline
What we’re doing: Building a complete data processing pipeline that showcases records, sealed classes, GINQ, switch expressions, and pattern matching in a realistic scenario.
Example 14: Modern Groovy Pipeline
// Domain model with records and sealed classes
record Student(String name, String major, List<Integer> grades) {}
sealed interface GradeResult permits Pass, Fail, Honors {}
record Pass(String name, double gpa) implements GradeResult {}
record Fail(String name, double gpa, String reason) implements GradeResult {}
record Honors(String name, double gpa, String distinction) implements GradeResult {}
// Grade calculator
def calculateGPA(List<Integer> grades) {
grades.isEmpty() ? 0.0 : (grades.sum() / grades.size() / 25.0).round(2)
}
// Evaluate student using pattern matching and switch
def evaluateStudent(Student student) {
var gpa = calculateGPA(student.grades())
switch (gpa) {
case { it >= 3.7 } -> new Honors(student.name(), gpa, switch(gpa) {
case { it >= 3.9 } -> 'Summa Cum Laude'
case { it >= 3.7 } -> 'Magna Cum Laude'
default -> 'Cum Laude'
})
case { it >= 2.0 } -> new Pass(student.name(), gpa)
default -> new Fail(student.name(), gpa, gpa == 0.0 ? 'No grades submitted' : 'GPA below minimum')
}
}
// Format result using pattern matching
def formatResult(GradeResult result) {
if (result instanceof Honors h) {
"HONORS: ${h.name()} - GPA: ${h.gpa()} (${h.distinction()})"
} else if (result instanceof Pass p) {
"PASS: ${p.name()} - GPA: ${p.gpa()}"
} else if (result instanceof Fail f) {
"FAIL: ${f.name()} - GPA: ${f.gpa()} (${f.reason()})"
}
}
// Student data
def students = [
new Student('Nirranjan', 'CS', [95, 98, 92, 97, 96]),
new Student('Viraj', 'Math', [78, 82, 75, 80, 79]),
new Student('Prathamesh', 'Physics', [92, 95, 88, 94, 91]),
new Student('Prathamesh', 'English', [65, 70, 58, 62, 60]),
new Student('Viraj', 'CS', [45, 38, 42, 50, 40]),
new Student('Frank', 'Math', [88, 90, 85, 92, 87]),
new Student('Grace', 'Physics', []),
]
// Evaluate all students
def results = students.collect { evaluateStudent(it) }
println "Student Evaluations:"
println "=" * 60
results.each { println " ${formatResult(it)}" }
// Statistics with GINQ
def studentData = students.collect { s ->
[name: s.name(), major: s.major(), gpa: calculateGPA(s.grades())]
}
def majorStats = GQ {
from s in studentData
where s.gpa > 0
groupby s.major
orderby avg(s.gpa) in desc
select s.major, count() as students, avg(s.gpa) as avgGPA
}
println "\nDepartment Rankings:"
println " ${'Major'.padRight(10)} Students Avg GPA"
majorStats.each { row ->
println " ${row.major.padRight(10)} ${row.students} ${row.avgGPA.round(2)}"
}
// Count by result type
def summary = results.countBy { it.getClass().simpleName }
println "\nResult Summary: ${summary}"
Output
Student Evaluations: ============================================================ HONORS: Nirranjan - GPA: 3.82 (Magna Cum Laude) PASS: Viraj - GPA: 3.17 HONORS: Prathamesh - GPA: 3.68 (Magna Cum Laude) PASS: Prathamesh - GPA: 2.52 FAIL: Viraj - GPA: 1.72 (GPA below minimum) PASS: Frank - GPA: 3.54 FAIL: Grace - GPA: 0.0 (No grades submitted) Department Rankings: Major Students Avg GPA CS 2 2.77 Math 2 3.36 Physics 1 3.68 English 1 2.52 Result Summary: [Honors:2, Pass:3, Fail:2]
What happened here: This pipeline combines every major modern Groovy feature. Records define the data model (Student, Pass, Fail, Honors). Sealed interfaces ensure GradeResult has exactly three variants. Switch expressions handle evaluation logic with nested switches for honor distinctions. Pattern matching with instanceof formats results type-safely. GINQ calculates department statistics with grouping and aggregation. GDK’s countBy provides the final summary. Each feature handles what it’s best at, and they compose cleanly together.
Common Pitfalls
Modern Groovy features are powerful but come with their own gotchas. Here’s what to watch for.
Pitfall 1: Records Are Not @Immutable
Records guarantee that their fields are final, but if a field holds a mutable object (like a List), the contents can still be modified.
Bad: Mutable collection in a record
// BAD: The list inside the record can be modified
record Team(String name, List<String> members) {}
def team = new Team('Alpha', ['Nirranjan', 'Viraj'])
team.members().add('Prathamesh') // This works! The record isn't truly immutable
println team.members() // [Nirranjan, Viraj, Prathamesh]
Good: Defensive copy in compact constructor
// GOOD: Make a defensive copy
record Team(String name, List<String> members) {
Team {
members = List.copyOf(members) // Unmodifiable copy
}
}
def team = new Team('Alpha', ['Nirranjan', 'Viraj'])
try {
team.members().add('Prathamesh')
} catch (UnsupportedOperationException e) {
println "Cannot modify: list is immutable"
}
Pitfall 2: GINQ Syntax Is Not Real SQL
GINQ looks like SQL but has important differences. Column names in the result are accessed differently, and not all SQL features are available.
Bad: Treating GINQ like SQL
// BAD: Using SQL subquery syntax
// def result = GQ {
// from e in employees
// where e.salary > (select avg(e2.salary) from e2 in employees) // Won't compile
// select e
// }
Good: Pre-compute and use Groovy variables
// GOOD: Compute the value first, use it in GINQ
record Emp(String name, int salary) {}
def employees = [new Emp('Nirranjan', 90000), new Emp('Viraj', 70000), new Emp('Prathamesh', 80000)]
def avgSalary = employees.collect { it.salary() }.average()
def aboveAvg = GQ {
from e in employees
where e.salary() > avgSalary
select e.name(), e.salary()
}
aboveAvg.each { println "${it[0]}: ${it[1]}" }
Pitfall 3: var Is Not def
While var and def look similar, they behave differently in terms of type inference, especially with @CompileStatic.
Key Difference
// def - dynamic type (Object at compile time) def x = 'hello' // x = 42 // This works with def - type can change // var - inferred type (String at compile time) var y = 'hello' // y = 42 // With @CompileStatic, this would be a compile error // Use def for dynamic code, var for type-safe code // Use explicit types when the right-hand side isn't obvious: var name = "Rahul" // inferred as String var count = 42 // inferred as Integer
Best Practices
Here are the guidelines for using Groovy 4/5 modern features effectively in production code.
- DO use records for all data-carrying classes that don’t need mutability. They’re cleaner than
@Immutableand interoperable with Java. - DO use sealed classes when you have a fixed set of subtypes. They make switch expressions exhaustive and prevent unexpected subclassing.
- DO use switch expressions instead of if-else chains when mapping values. They’re more readable and the compiler checks for completeness.
- DO use GINQ for complex collection transformations that involve grouping, joining, or aggregating. For simple filters,
findAll/collectis still cleaner. - DO make defensive copies in record compact constructors when fields hold mutable objects like
ListorMap. - DON’T use
vareverywhere. Use it when the type is obvious from context (var name = 'Nirranjan'). Use explicit types when the right-hand side is a method call with a non-obvious return type. - DON’T treat GINQ as a replacement for all collection processing. Simple operations like
findAllandcollectare faster to write and read for simple transformations. - DON’T use virtual threads for CPU-bound work. They’re designed for I/O-bound tasks (network calls, file I/O, database queries). For CPU-bound parallelism, use the traditional
ForkJoinPoolorGPars. - DON’T forget that records require accessing fields with method syntax:
person.name(), notperson.name. Groovy’s property access convention does not apply to record components.
Conclusion
Groovy 4 and 5 aren’t incremental updates – they represent a fundamental modernization of the language. Records eliminate data class boilerplate. Sealed classes give you closed type hierarchies. Switch expressions turn branching into concise value mappings. Pattern matching removes explicit casting. GINQ brings SQL-like queries to collections. Virtual threads unlock massive concurrency without the complexity of traditional thread management.
The most important takeaway: these features compose beautifully. Records work with sealed classes. Sealed classes work with switch expressions. Switch expressions work with pattern matching. GINQ works with records. You don’t adopt them one at a time – you adopt them together, and the result is code that’s shorter, safer, and more expressive than anything possible in Groovy 3.x.
If you’re still on Groovy 3.x or early 4.x, the upgrade path is simple. Start by moving to Java 17, update your Groovy dependency, fix any deprecated API usage, and start introducing records and switch expressions in new code. For migration details, check our Groovy 5 New Features post. For the AST transforms that complement these features, see More AST Transformations in Groovy.
This is the final post in our 115-lesson Groovy tutorial series. We started with Hello World and ended with virtual threads and GINQ queries. If you’ve followed along, you now have a solid, practical understanding of Groovy – from basics to advanced metaprogramming to the newest features in modern JVM development.
Up next: Explore all 115 lessons in our Groovy Tutorial series.
Frequently Asked Questions
What are Groovy records and how do they differ from @Immutable?
Groovy records (available since Groovy 4.0) are a language-level feature for creating immutable data classes. They automatically generate a canonical constructor, component accessor methods, equals(), hashCode(), and toString(). Unlike @Immutable, records are a JVM-standard feature that Java tools understand natively. Records use method-style accessors like name() instead of property-style name. Records can also have compact constructors for validation and custom methods for business logic.
What is GINQ in Groovy and when should I use it?
GINQ (Groovy Integrated Query) is a SQL-like query language built into Groovy 4.0+ that lets you query in-memory collections using familiar keywords like from, where, join, groupby, orderby, and select. Use GINQ when you need to join multiple collections, perform complex aggregations (sum, count, avg), or write queries that would require deeply nested groupBy/collectEntries/findAll chains. For simple filters and transformations, stick with standard GDK methods like findAll and collect.
How do sealed classes work in Groovy?
Sealed classes (or interfaces) restrict which classes can extend or implement them using the sealed keyword and permits clause. For example, sealed interface Shape permits Circle, Rectangle means only Circle and Rectangle can implement Shape. This creates a closed type hierarchy that the compiler can reason about – switch expressions over sealed types can be exhaustive. Permitted subclasses must be final, sealed, or non-sealed. Sealed classes are available since Groovy 4.0.
Can I use virtual threads with Groovy?
Yes, if you’re running on Java 21 or higher. Virtual threads are a JVM feature, and Groovy accesses them through the standard java.util.concurrent APIs. Use Executors.newVirtualThreadPerTaskExecutor() to create an executor that spawns virtual threads. Virtual threads are ideal for I/O-bound tasks (API calls, database queries, file operations) where you need thousands of concurrent operations. They are not beneficial for CPU-bound work – use traditional thread pools or GPars for parallel computation.
What is the difference between var and def in Groovy?
def declares a dynamically typed variable – the compiler treats it as Object and type checks happen at runtime. var (available since Groovy 4.0) declares a variable with inferred type – the compiler determines the type from the right-hand side and enforces it at compile time. With @CompileStatic, var gives you full type safety and IDE support while keeping code concise. Use var when the type is obvious from context, explicit types when the return type is non-obvious, and def when you intentionally want dynamic typing.
Related Posts
Previous in Series: More AST Transformations in Groovy
Series Complete! This is the final post in our 115-lesson Groovy tutorial series. Start from the beginning: Groovy Hello World.
Related Topics You Might Like:
- Groovy 5 New Features – What’s New and How to Migrate
- More AST Transformations in Groovy
- Groovy AST Transformations – Compile-Time Metaprogramming
This post is part of the Groovy & Grails Cookbook series on TechnoScripts.com

No comment