Groovy classes and inheritance are explained here with 12 tested examples. Learn class declaration, properties, methods, abstract classes, and polymorphism on Groovy 5.x.
“A well-designed class hierarchy is like a well-organized toolbox – everything has its place, and you can find what you need without digging.”
Grady Booch, Object-Oriented Analysis and Design
Last Updated: March 2026 | Tested on: Groovy 5.x, Java 17+ | Difficulty: Intermediate | Reading Time: 20 minutes
Object-oriented programming is the backbone of most real-world applications, and Groovy classes – as described in the official Groovy object orientation documentation – make OOP genuinely enjoyable. If you’ve ever written Java classes and wished there was less ceremony involved – fewer explicit getters, fewer constructors, fewer lines of code for the same result – then Groovy is about to make your day.
In this guide, we’ll walk through the fundamentals of Groovy classes and inheritance. We’ll start with the basics of class declaration and work our way up to abstract classes, polymorphism, and practical design patterns. Every example has been tested on Groovy 5.x with Java 17+, and each one includes the actual output so you can verify on your own machine.
If you’re new to Groovy and haven’t explored variable declarations yet, take a quick detour to our Groovy def Keyword guide. If you’re already comfortable with Groovy basics, let’s jump right in.
Table of Contents
What Are Groovy Classes?
At their core, Groovy classes are just like Java classes – they define a blueprint for objects with properties (state) and methods (behavior). But Groovy strips away the boilerplate that makes Java verbose. Here’s what you get out of the box:
- Auto-generated getters and setters – declare a field, and Groovy creates the accessor methods for you
- Default imports – common packages like
java.util.*andjava.io.*are imported automatically - Map-based constructors – create objects using named parameters without writing a single constructor
- Optional typing – use
deffor dynamic typing or explicit types for compile-time safety - Default public visibility – classes and methods are public by default (no need to type
publiceverywhere)
Every Groovy class ultimately extends java.lang.Object and implements groovy.lang.GroovyObject, which gives it access to Groovy’s metaprogramming capabilities. But you don’t need to think about that until you get into advanced territory.
Classes vs Scripts in Groovy
Before we start with examples, let’s clear up a common confusion. Groovy supports two modes of execution:
- Script mode – code runs top-to-bottom without an explicit class. Under the hood, Groovy wraps it in a class that extends
groovy.lang.Script. - Class mode – you define explicit classes with a
mainmethod or use them from scripts.
In this tutorial, we’ll define classes and then instantiate them in script mode – which is the most common approach for learning and prototyping. In production Groovy projects (like Grails apps), you’ll define classes in their own files just like you would in Java.
12 Practical Examples of Groovy Classes and Inheritance
Let’s work through 12 hands-on examples. Each one builds on the previous concepts, so I’d recommend going through them in order if this is your first time with Groovy OOP.
Example 1: Basic Class Declaration and Instantiation
The simplest Groovy class is remarkably simple. No public keyword needed, no file-name restrictions (you can put multiple classes in one file), and no semicolons required.
Basic Class Declaration
// Define a simple class
class Person {
String name
int age
}
// Instantiate using the default constructor
def person1 = new Person()
person1.name = "Alice"
person1.age = 30
println "Name: ${person1.name}, Age: ${person1.age}"
// Instantiate using the map-based constructor (Groovy magic)
def person2 = new Person(name: "Bob", age: 25)
println "Name: ${person2.name}, Age: ${person2.age}"
// You can also set properties after creation
person2.age = 26
println "Updated Age: ${person2.age}"
Output
Name: Alice, Age: 30 Name: Bob, Age: 25 Updated Age: 26
Notice that we didn’t write a single getter, setter, or constructor. Groovy generates all of that for you. When you write person1.name, Groovy is actually calling person1.getName() behind the scenes. This is one of the biggest quality-of-life improvements over Java.
Example 2: Properties (Auto-Generated Getters and Setters)
Groovy’s property system is one of its best features. Any field declared without an access modifier gets automatic getter and setter methods. You can also override them when you need custom logic.
Groovy Properties and Custom Accessors
class Product {
String name
double price
int quantity
// Custom setter with validation
void setPrice(double price) {
if (price < 0) {
throw new IllegalArgumentException("Price cannot be negative")
}
this.price = price
}
// Custom getter with formatting
String getFormattedPrice() {
return "\$${String.format('%.2f', price)}"
}
// Read-only computed property
double getTotalValue() {
return price * quantity
}
}
def laptop = new Product(name: "MacBook Pro", price: 1999.99, quantity: 5)
println "Product: ${laptop.name}"
println "Price: ${laptop.formattedPrice}"
println "Total Value: \$${laptop.totalValue}"
// Test the validation
try {
laptop.price = -100
} catch (IllegalArgumentException e) {
println "Caught: ${e.message}"
}
Output
Product: MacBook Pro Price: $1999.99 Total Value: $9999.95 Caught: Price cannot be negative
The key insight here is that laptop.formattedPrice looks like a property access, but it’s actually calling getFormattedPrice(). This is Groovy’s property convention – any method named getXxx() can be accessed as object.xxx. This makes your API cleaner without sacrificing encapsulation.
Example 3: Methods and Return Types
Groovy methods can declare explicit return types or use def for dynamic typing. You can also omit the return keyword – the last expression in a method is automatically returned. For more on the def keyword, check our dedicated guide.
Methods with Various Return Types
class Calculator {
// Explicit return type
int add(int a, int b) {
return a + b
}
// Implicit return (last expression is returned)
int multiply(int a, int b) {
a * b
}
// Dynamic return type with def
def divide(double a, double b) {
if (b == 0) {
return "Cannot divide by zero" // returns String
}
a / b // returns Double
}
// Method with default parameter values
double power(double base, int exponent = 2) {
Math.pow(base, exponent)
}
// Method accepting a closure
def operate(int a, int b, Closure operation) {
operation(a, b)
}
}
def calc = new Calculator()
println "Add: ${calc.add(10, 5)}"
println "Multiply: ${calc.multiply(4, 7)}"
println "Divide: ${calc.divide(10, 3)}"
println "Divide by zero: ${calc.divide(10, 0)}"
println "Power (default): ${calc.power(5)}"
println "Power (custom): ${calc.power(2, 10)}"
// Passing a closure as an argument
def result = calc.operate(15, 3) { a, b -> a - b }
println "Custom operation: ${result}"
Output
Add: 15 Multiply: 28 Divide: 3.3333333333 Divide by zero: Cannot divide by zero Power (default): 25.0 Power (custom): 1024.0 Custom operation: 12
Default parameter values are a great Groovy feature – they eliminate the need for method overloading in many cases. And if you haven’t explored Groovy closures yet, the last example shows how naturally they work with class methods.
Example 4: Constructors (Default, Custom, and Map-Based)
Groovy gives you three kinds of constructors without writing much code. The map-based constructor is particularly powerful and is something Java developers often envy.
Constructor Varieties in Groovy
class Employee {
String name
String department
double salary
// Default constructor is provided automatically
// But you can also define custom constructors
// Custom constructor with all fields
Employee(String name, String department, double salary) {
this.name = name
this.department = department
this.salary = salary
println "Custom constructor called for ${name}"
}
// Overloaded constructor with defaults
Employee(String name) {
this(name, "Unassigned", 0.0)
}
String toString() {
"${name} | ${department} | \$${salary}"
}
}
// Using the full custom constructor
def emp1 = new Employee("Alice", "Engineering", 95000)
println emp1
// Using the overloaded constructor
def emp2 = new Employee("Bob")
println emp2
// Note: When you define custom constructors, the default
// no-arg and map-based constructors are no longer auto-generated.
// To keep the map-based constructor, use @MapConstructor or @TupleConstructor.
import groovy.transform.MapConstructor
import groovy.transform.TupleConstructor
@TupleConstructor
@MapConstructor
class Project {
String title
String status = "Active"
int priority = 1
String toString() {
"${title} [${status}] (Priority: ${priority})"
}
}
// Map-based constructor
def p1 = new Project(title: "Website Redesign", priority: 3)
println p1
// Tuple constructor (positional)
def p2 = new Project("API Migration", "In Progress", 2)
println p2
// Default values kick in when not specified
def p3 = new Project(title: "Documentation")
println p3
Output
Custom constructor called for Alice Alice | Engineering | $95000.0 Custom constructor called for Bob Bob | Unassigned | $0.0 Website Redesign [Active] (Priority: 3) API Migration [In Progress] (Priority: 2) Documentation [Active] (Priority: 1)
A common pitfall: once you define any custom constructor, Groovy no longer generates the default no-arg constructor or the map-based constructor. That’s why we use @MapConstructor and @TupleConstructor annotations – they bring those convenient constructors back while letting you keep explicit control.
Example 5: Inheritance with extends
Inheritance in Groovy works exactly like Java – you use extends to create a subclass. The child class inherits all non-private properties and methods from the parent. Groovy supports single inheritance only (one parent class), but you can implement multiple interfaces and use traits for mixin-style reuse.
Basic Inheritance with extends
class Animal {
String name
int age
Animal(String name, int age) {
this.name = name
this.age = age
}
String speak() {
"..."
}
String describe() {
"${name} is ${age} years old"
}
}
class Dog extends Animal {
String breed
Dog(String name, int age, String breed) {
super(name, age) // Call parent constructor
this.breed = breed
}
// Override the speak method
String speak() {
"Woof! Woof!"
}
String fetch(String item) {
"${name} fetches the ${item}!"
}
}
class Cat extends Animal {
boolean isIndoor
Cat(String name, int age, boolean isIndoor) {
super(name, age)
this.isIndoor = isIndoor
}
String speak() {
"Meow!"
}
String lifestyle() {
isIndoor ? "${name} is an indoor cat" : "${name} roams outside"
}
}
def dog = new Dog("Rex", 5, "German Shepherd")
def cat = new Cat("Whiskers", 3, true)
println dog.describe() // Inherited method
println "Dog says: ${dog.speak()}" // Overridden method
println dog.fetch("ball") // Dog-specific method
println cat.describe() // Inherited method
println "Cat says: ${cat.speak()}" // Overridden method
println cat.lifestyle() // Cat-specific method
// Both are Animals
println "Is Dog an Animal? ${dog instanceof Animal}"
println "Is Cat an Animal? ${cat instanceof Animal}"
Output
Rex is 5 years old Dog says: Woof! Woof! Rex fetches the ball! Whiskers is 3 years old Cat says: Meow! Whiskers is an indoor cat Is Dog an Animal? true Is Cat an Animal? true
The instanceof operator confirms the “is-a” relationship. A Dog is-an Animal, and a Cat is-an Animal. This is the fundamental principle of inheritance – subclasses are specialized versions of their parent class.
Example 6: Method Overriding
Method overriding lets a subclass provide its own implementation of a method defined in the parent class. In Groovy, you don’t need the @Override annotation (unlike Java, where it’s strongly recommended), but it’s still good practice to use it – it catches typos in method names at compile time.
Method Overriding with @Override
class Shape {
String color = "black"
double area() {
0.0
}
double perimeter() {
0.0
}
String toString() {
"${this.getClass().simpleName} [color=${color}, area=${String.format('%.2f', area())}]"
}
}
class Circle extends Shape {
double radius
Circle(double radius, String color = "black") {
this.radius = radius
this.color = color
}
@Override
double area() {
Math.PI * radius * radius
}
@Override
double perimeter() {
2 * Math.PI * radius
}
}
class Rectangle extends Shape {
double width
double height
Rectangle(double width, double height, String color = "black") {
this.width = width
this.height = height
this.color = color
}
@Override
double area() {
width * height
}
@Override
double perimeter() {
2 * (width + height)
}
}
class Square extends Rectangle {
Square(double side, String color = "black") {
super(side, side, color)
}
@Override
String toString() {
"Square [side=${width}, color=${color}, area=${String.format('%.2f', area())}]"
}
}
def shapes = [
new Circle(5, "red"),
new Rectangle(4, 6, "blue"),
new Square(3, "green")
]
shapes.each { shape ->
println shape
println " Perimeter: ${String.format('%.2f', shape.perimeter())}"
}
Output
Circle [color=red, area=78.54] Perimeter: 31.42 Rectangle [color=blue, area=24.00] Perimeter: 20.00 Square [side=3.0, color=green, area=9.00] Perimeter: 12.00
Notice how Square extends Rectangle – this is multi-level inheritance. The Square class overrides toString() but inherits area() and perimeter() from Rectangle. This is a classic example of the Liskov Substitution Principle in action: any Square can be used wherever a Rectangle is expected.
Example 7: Abstract Classes
Abstract classes define a contract that subclasses must fulfill. They can contain both implemented methods (shared behavior) and abstract methods (must be overridden). You cannot instantiate an abstract class directly. If you need a pure contract with no implementation, consider using interfaces instead.
Abstract Classes in Groovy
abstract class Vehicle {
String make
String model
int year
Vehicle(String make, String model, int year) {
this.make = make
this.model = model
this.year = year
}
// Abstract methods - subclasses MUST implement these
abstract String fuelType()
abstract double fuelEfficiency()
// Concrete method - shared by all subclasses
String description() {
"${year} ${make} ${model}"
}
// Template method pattern
String report() {
"""Vehicle Report:
${description()}
Fuel: ${fuelType()}
Efficiency: ${fuelEfficiency()} mpg"""
}
}
class GasCar extends Vehicle {
double tankSize
GasCar(String make, String model, int year, double tankSize) {
super(make, model, year)
this.tankSize = tankSize
}
@Override
String fuelType() { "Gasoline" }
@Override
double fuelEfficiency() { 28.5 }
}
class ElectricCar extends Vehicle {
double batteryCapacity
ElectricCar(String make, String model, int year, double batteryCapacity) {
super(make, model, year)
this.batteryCapacity = batteryCapacity
}
@Override
String fuelType() { "Electric" }
@Override
double fuelEfficiency() { 120.0 } // MPGe for electric
// Additional method specific to electric cars
double estimatedRange() {
batteryCapacity * 3.5
}
}
// Cannot do: def v = new Vehicle(...) -- would throw an error
def cars = [
new GasCar("Toyota", "Camry", 2025, 15.8),
new ElectricCar("Tesla", "Model 3", 2025, 75.0)
]
cars.each { car ->
println car.report()
if (car instanceof ElectricCar) {
println " Estimated Range: ${car.estimatedRange()} miles"
}
println ""
}
// Try to instantiate abstract class
try {
def v = new Vehicle("Test", "Car", 2025) {
String fuelType() { "Unknown" }
double fuelEfficiency() { 0.0 }
}
println "Anonymous subclass: ${v.description()}"
} catch (Exception e) {
println "Error: ${e.message}"
}
Output
Vehicle Report: 2025 Toyota Camry Fuel: Gasoline Efficiency: 28.5 mpg Vehicle Report: 2025 Tesla Model 3 Fuel: Electric Efficiency: 120.0 mpg Estimated Range: 262.5 miles Anonymous subclass: 2025 Test Car
The report() method demonstrates the Template Method design pattern – the abstract class defines the algorithm’s structure while letting subclasses fill in the details. Also notice the anonymous subclass at the end – you can create inline implementations of abstract classes, which is handy for testing and one-off usage.
Example 8: The super Keyword
The super keyword lets you access the parent class’s constructor, methods, and properties from a subclass. This is essential when you want to extend parent behavior rather than completely replace it.
Using super to Access Parent Members
class Logger {
String prefix
Logger(String prefix = "LOG") {
this.prefix = prefix
}
String format(String message) {
"[${prefix}] ${message}"
}
void log(String message) {
println format(message)
}
}
class TimestampLogger extends Logger {
String dateFormat
TimestampLogger(String prefix = "LOG", String dateFormat = "yyyy-MM-dd HH:mm:ss") {
super(prefix) // Call parent constructor
this.dateFormat = dateFormat
}
@Override
String format(String message) {
def timestamp = new Date().format(dateFormat)
// Extend parent's format with timestamp
"${super.format(message)} @ ${timestamp}"
}
}
class FileLogger extends TimestampLogger {
String filePath
FileLogger(String filePath, String prefix = "FILE") {
super(prefix) // Calls TimestampLogger's constructor
this.filePath = filePath
}
@Override
void log(String message) {
// Call parent's log first
super.log(message)
// Then add file-specific behavior
println " -> Would also write to: ${filePath}"
}
}
println "=== Basic Logger ==="
def logger = new Logger("APP")
logger.log("Application started")
println "\n=== Timestamp Logger ==="
def tsLogger = new TimestampLogger("DEBUG")
tsLogger.log("Processing request")
println "\n=== File Logger ==="
def fileLogger = new FileLogger("/var/log/app.log", "AUDIT")
fileLogger.log("User login event")
Output
=== Basic Logger === [APP] Application started === Timestamp Logger === [DEBUG] Processing request @ 2026-03-10 12:00:00 === File Logger === [AUDIT] User login event @ 2026-03-10 12:00:00 -> Would also write to: /var/log/app.log
The key pattern here is super.format(message) in TimestampLogger – it calls the parent’s format method and then adds its own behavior. This is called method extension and it’s a much better pattern than copy-pasting the parent’s code. Similarly, FileLogger.log() calls super.log() to get the timestamp formatting, then adds file-writing behavior on top.
Example 9: Access Modifiers (public, private, protected)
Groovy supports the same access modifiers as Java, but with an important difference: the default visibility is public, not package-private. Here’s the breakdown:
- public (default) – accessible from anywhere
- protected – accessible from the class, subclasses, and same package
- private – accessible only within the declaring class
- package-private – use
@PackageScopeannotation for Java-style package-private access
Access Modifiers in Groovy
class BankAccount {
String ownerName // public (default) - auto getter/setter
private double balance // private - no auto getter/setter
protected String accountId // protected - accessible in subclasses
BankAccount(String ownerName, double initialBalance) {
this.ownerName = ownerName
this.balance = initialBalance
this.accountId = "ACC-${System.currentTimeMillis()}"
}
// Public method to access private field
double getBalance() {
balance
}
void deposit(double amount) {
validateAmount(amount)
balance += amount
println "Deposited \$${amount}. New balance: \$${balance}"
}
void withdraw(double amount) {
validateAmount(amount)
if (amount > balance) {
throw new IllegalStateException("Insufficient funds")
}
balance -= amount
println "Withdrew \$${amount}. New balance: \$${balance}"
}
// Private helper method - not accessible outside this class
private void validateAmount(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Amount must be positive")
}
}
String toString() {
"BankAccount(owner=${ownerName}, balance=\$${balance})"
}
}
class SavingsAccount extends BankAccount {
double interestRate
SavingsAccount(String ownerName, double initialBalance, double interestRate) {
super(ownerName, initialBalance)
this.interestRate = interestRate
}
void applyInterest() {
double interest = balance * interestRate
deposit(interest)
// We CAN access 'accountId' here (protected)
println "Interest applied to account: ${accountId}"
}
}
def savings = new SavingsAccount("Alice", 1000.0, 0.05)
println savings
savings.deposit(500)
savings.applyInterest()
println "Final balance: \$${savings.balance}"
// Try accessing private method (Groovy is lenient at runtime!)
println "\n--- Access modifier note ---"
println "In Groovy, private is enforced at compile time with @CompileStatic"
println "but at runtime, Groovy's MOP allows access to private members"
Output
BankAccount(owner=Alice, balance=$1000.0) Deposited $500.0. New balance: $1500.0 Deposited $75.0. New balance: $1575.0 Interest applied to account: ACC-1741608000000 Final balance: $1575.0 --- Access modifier note --- In Groovy, private is enforced at compile time with @CompileStatic but at runtime, Groovy's MOP allows access to private members
An important gotcha: Groovy’s dynamic nature means that private members can actually be accessed at runtime through the MOP (Meta-Object Protocol). If you want strict access control, use @CompileStatic on your class – that enforces Java-style visibility rules at compile time. For most practical purposes, though, treating private as a convention works well.
Example 10: Static Methods and Properties
Static members belong to the class itself, not to any instance. They’re great for utility methods, factory patterns, and shared state. Groovy handles statics the same way Java does, but with cleaner syntax.
Static Methods and Properties
class IdGenerator {
// Static property shared across all instances
private static int counter = 0
// Static constant
static final String PREFIX = "ID"
// Static method
static String nextId() {
counter++
"${PREFIX}-${String.format('%04d', counter)}"
}
static int getCount() {
counter
}
static void reset() {
counter = 0
}
}
class User {
String id
String username
String email
// Factory method using static
static User create(String username, String email) {
new User(
id: IdGenerator.nextId(),
username: username,
email: email
)
}
// Static utility method
static boolean isValidEmail(String email) {
email ==~ /^[\w.+-]+@[\w-]+\.[\w.]+$/
}
String toString() {
"[${id}] ${username} <${email}>"
}
}
// Using static methods
println "Valid email? ${User.isValidEmail('alice@example.com')}"
println "Valid email? ${User.isValidEmail('not-an-email')}"
// Factory pattern
def user1 = User.create("alice", "alice@example.com")
def user2 = User.create("bob", "bob@example.com")
def user3 = User.create("charlie", "charlie@example.com")
println user1
println user2
println user3
println "Total IDs generated: ${IdGenerator.count}"
// Static members are accessed via the class, not instances
println "Prefix: ${IdGenerator.PREFIX}"
Output
Valid email? true Valid email? false [ID-0001] alice <alice@example.com> [ID-0002] bob <bob@example.com> [ID-0003] charlie <charlie@example.com> Total IDs generated: 3 Prefix: ID
The factory method pattern (User.create()) is a common use of static methods. It encapsulates object creation logic, making it easy to add ID generation, validation, or logging in one place. For constants, prefer static final – Groovy also supports enums which are often a better choice for a fixed set of values.
Example 11: Inner and Nested Classes
Groovy supports both static nested classes and inner classes (non-static). Static nested classes are independent of the outer class, while inner classes have an implicit reference to the outer class instance.
Inner and Nested Classes
class ShoppingCart {
String customerName
List<Item> items = []
ShoppingCart(String customerName) {
this.customerName = customerName
}
// Static nested class - doesn't need outer class instance
static class Item {
String name
double price
int quantity
Item(String name, double price, int quantity = 1) {
this.name = name
this.price = price
this.quantity = quantity
}
double getSubtotal() {
price * quantity
}
String toString() {
"${name} x${quantity} = \$${String.format('%.2f', subtotal)}"
}
}
// Non-static inner class - has access to outer class
class OrderSummary {
String generateReport() {
def total = items.sum { it.subtotal } ?: 0.0
def lines = ["Order Summary for ${customerName}"]
lines << "-" * 40
items.each { item ->
lines << " ${item}"
}
lines << "-" * 40
lines << " Total: \$${String.format('%.2f', total)}"
lines.join("\n")
}
}
void addItem(String name, double price, int quantity = 1) {
items << new Item(name, price, quantity)
}
String checkout() {
def summary = new OrderSummary()
summary.generateReport()
}
}
// Static nested class can be created independently
def item = new ShoppingCart.Item("Test Item", 9.99, 2)
println "Standalone item: ${item}"
// Inner class usage through outer class
def cart = new ShoppingCart("Alice")
cart.addItem("Keyboard", 79.99)
cart.addItem("Mouse", 39.99)
cart.addItem("USB Cable", 12.99, 3)
println "\n${cart.checkout()}"
// Anonymous inner class example
def comparator = new Comparator<ShoppingCart.Item>() {
int compare(ShoppingCart.Item a, ShoppingCart.Item b) {
a.subtotal <=> b.subtotal // Groovy's spaceship operator
}
}
def sorted = cart.items.sort(false, comparator)
println "\nItems sorted by subtotal:"
sorted.each { println " ${it}" }
Output
Standalone item: Test Item x2 = $19.98 Order Summary for Alice ---------------------------------------- Keyboard x1 = $79.99 Mouse x1 = $39.99 USB Cable x3 = $38.97 ---------------------------------------- Total: $158.95 Items sorted by subtotal: USB Cable x3 = $38.97 Mouse x1 = $39.99 Keyboard x1 = $79.99
The key difference: ShoppingCart.Item is a static nested class, so you can create it with new ShoppingCart.Item(...) without a cart. The OrderSummary is a non-static inner class, so it has access to customerName and items from the enclosing ShoppingCart instance. Use static nested classes for logically grouped types that don’t need outer state. Use inner classes when you need that connection.
Example 12: Polymorphism in Practice
Polymorphism is where inheritance really shines. It lets you write code that works with a parent type, and each subclass provides its own behavior at runtime. Groovy’s dynamic nature makes polymorphism even more flexible than in Java – you can even use duck typing (“if it walks like a duck…”).
Polymorphism in Action
abstract class Notification {
String recipient
String message
Date timestamp = new Date()
Notification(String recipient, String message) {
this.recipient = recipient
this.message = message
}
// Abstract method - each channel implements differently
abstract boolean send()
// Common method
String getLogEntry() {
"[${timestamp.format('HH:mm:ss')}] ${getClass().simpleName} to ${recipient}: ${message}"
}
}
class EmailNotification extends Notification {
String subject
EmailNotification(String recipient, String message, String subject) {
super(recipient, message)
this.subject = subject
}
@Override
boolean send() {
println "Sending EMAIL to ${recipient}"
println " Subject: ${subject}"
println " Body: ${message}"
true // Simulate success
}
}
class SmsNotification extends Notification {
String phoneNumber
SmsNotification(String recipient, String message, String phoneNumber) {
super(recipient, message)
this.phoneNumber = phoneNumber
}
@Override
boolean send() {
def truncated = message.length() > 160 ? message[0..156] + "..." : message
println "Sending SMS to ${phoneNumber} (${recipient})"
println " Message: ${truncated}"
true
}
}
class SlackNotification extends Notification {
String channel
SlackNotification(String recipient, String message, String channel) {
super(recipient, message)
this.channel = channel
}
@Override
boolean send() {
println "Posting to Slack #${channel} (@${recipient})"
println " Message: ${message}"
true
}
}
// The power of polymorphism: process all notifications uniformly
class NotificationService {
List<String> logs = []
void sendAll(List<Notification> notifications) {
println "=== Sending ${notifications.size()} Notifications ===\n"
int success = 0
int failed = 0
notifications.each { notification ->
// Each subclass's send() is called - polymorphism!
if (notification.send()) {
success++
} else {
failed++
}
logs << notification.logEntry
println ""
}
println "=== Results: ${success} sent, ${failed} failed ==="
}
}
// Create mixed notifications
def notifications = [
new EmailNotification("alice@example.com", "Your order has shipped!", "Order #1234 Update"),
new SmsNotification("Bob", "Your code is 847293", "+1-555-0123"),
new SlackNotification("charlie", "Deployment to prod complete", "engineering"),
new EmailNotification("dave@example.com", "Weekly report attached", "Weekly Report - March 2026")
]
// Process them all through one service
def service = new NotificationService()
service.sendAll(notifications)
println "\n=== Notification Log ==="
service.logs.each { println it }
Output
=== Sending 4 Notifications === Sending EMAIL to alice@example.com Subject: Order #1234 Update Body: Your order has shipped! Sending SMS to +1-555-0123 (Bob) Message: Your code is 847293 Posting to Slack #engineering (@charlie) Message: Deployment to prod complete Sending EMAIL to dave@example.com Subject: Weekly Report - March 2026 Body: Weekly report attached === Results: 4 sent, 0 failed === === Notification Log === [12:00:00] EmailNotification to alice@example.com: Your order has shipped! [12:00:00] SmsNotification to Bob: Your code is 847293 [12:00:00] SlackNotification to charlie: Deployment to prod complete [12:00:00] EmailNotification to dave@example.com: Weekly report attached
This is the real-world payoff of polymorphism. The NotificationService.sendAll() method doesn’t know or care about the specific notification type. It just calls send(), and each subclass does the right thing. Adding a new notification channel (say, PushNotification) requires zero changes to the service – just create a new subclass. This is the Open/Closed Principle: open for extension, closed for modification.
If you need behavior composition beyond single inheritance, check out Groovy Traits – they let you mix in multiple behaviors without the diamond problem.
Best Practices for Groovy OOP
After working through those 12 examples, here are the practices that will keep your Groovy OOP code clean and maintainable:
Class Design
- Favor composition over inheritance – Use inheritance for “is-a” relationships and composition (injecting dependencies) for “has-a” relationships. Not everything needs a class hierarchy.
- Keep inheritance hierarchies shallow – More than 3 levels deep is usually a design smell. Consider using traits or interfaces for shared behavior.
- Use
@Immutablefor value objects – When a class just holds data and shouldn’t change after creation, mark it @Immutable. Groovy generatesequals(),hashCode(), andtoString()for free. - Override
toString()– Always provide a meaningfultoString(). It makes debugging dramatically easier.
Properties and Methods
- Let Groovy generate getters/setters – Only write custom accessors when you need validation or transformation. The auto-generated ones are fine for most cases.
- Use explicit types for public APIs – While
defis great for local variables and scripts, public methods should have explicit return types for clarity and IDE support. - Use default parameter values – Instead of writing three overloaded constructors, use default values. It’s cleaner and more Groovy-idiomatic.
- Use
@Overrideannotation – Even though Groovy doesn’t require it,@Overridecatches method signature typos at compile time.
Inheritance and Access Control
- Use abstract classes for shared state + behavior – If your base type has fields and some shared method implementations, an abstract class is the right choice.
- Use interfaces for pure contracts – If you just need to define a set of methods without shared state, use interfaces.
- Use
@CompileStaticfor strict encapsulation – If you want Java-style access modifier enforcement, annotate your class with@CompileStatic. - Prefer
privatefor internal state – Mark fieldsprivateand expose them through controlled methods. Even if Groovy’s MOP can bypass it at runtime, it signals your intent clearly.
Error Handling
- Validate in constructors and setters – Don’t let invalid objects exist. If a price can’t be negative, throw an exception in the setter. Check our Groovy Exception Handling guide for patterns.
- Use custom exceptions for domain errors – Instead of throwing generic
RuntimeException, create domain-specific exception classes that extend appropriate base exceptions.
Testing and Maintainability
- Design for testability – Keep your constructors simple and inject dependencies rather than creating them internally. This makes unit testing simple because you can pass in mock objects.
- Follow the Single Responsibility Principle – Each class should have one reason to change. If your class handles both data validation and database persistence, split it into two classes. This makes your code easier to test, debug, and extend.
- Use
@ToStringand@EqualsAndHashCode– Groovy provides AST transformation annotations that generate these methods automatically. Use@ToString(includeNames=true)for readable debug output and@EqualsAndHashCodewhen your objects are used in collections or maps. - Document your class hierarchy – When you have more than two levels of inheritance, add Groovydoc comments explaining why each class exists and what role it plays. Future developers (including yourself in six months) will thank you.
Common Mistakes to Avoid
Here are some pitfalls that trip up developers when working with Groovy classes:
- Forgetting that custom constructors remove the default ones – Once you define any constructor, Groovy stops generating the no-arg and map-based constructors. Use
@MapConstructoror@TupleConstructorto get them back. - Confusing property access with field access –
this.nameinside a class accesses the field directly, whilenamewithoutthismay go through the getter. This matters when you have custom getters with side effects. - Deep inheritance hierarchies – If you find yourself extending class after class after class, step back and consider whether traits or composition would be a better fit. Deep hierarchies are fragile and hard to refactor.
- Relying on dynamic access to private members – Just because Groovy lets you access private fields at runtime doesn’t mean you should. Treat access modifiers as contracts, and use
@CompileStaticin production code for enforcement.
Conclusion
Groovy classes give you all the power of Java’s object-oriented programming with significantly less ceremony. Auto-generated getters and setters, map-based constructors, default parameter values, and optional typing – these features let you focus on the design of your classes rather than the mechanics of writing them.
Inheritance in Groovy follows the same rules as Java: single class inheritance, extends for subclassing, super for accessing parent members, and abstract classes for defining contracts with shared behavior. Polymorphism works beautifully, letting you write flexible code that handles different types through a common interface.
As your projects grow, remember to complement inheritance with traits for cross-cutting concerns, interfaces for pure contracts, and @Immutable for value objects. Good class design is not about using every OOP feature – it’s about choosing the right tool for each problem.
Summary
- Groovy classes are public by default with auto-generated getters, setters, and a map-based constructor
- Properties provide clean access syntax –
obj.namecallsobj.getName()behind the scenes - Constructors come in three flavors: default, custom, and map-based (use
@MapConstructor/@TupleConstructorannotations) - Inheritance uses
extendsfor single-class inheritance,superto access parent members - Abstract classes define contracts with shared behavior; use interfaces for pure contracts
- Polymorphism lets you write flexible, extensible code that handles multiple types uniformly
- Use
@CompileStaticwhen you need strict type checking and access modifier enforcement
If you also work with build tools, CI/CD pipelines, or cloud CLIs, check out Command Playground to practice 105+ CLI tools directly in your browser — no install needed.
Up next: Learn how to define pure contracts with Groovy Interfaces, the natural companion to classes and inheritance.
Frequently Asked Questions
What is the difference between Groovy classes and Java classes?
Groovy classes are public by default (Java is package-private), automatically generate getters and setters for properties, provide a map-based constructor out of the box, support optional typing with def, and don’t require semicolons. Under the hood, every Groovy class implements GroovyObject, which gives it access to metaprogramming features like methodMissing() and propertyMissing(). Structurally, Groovy classes compile to standard JVM bytecode and are fully interoperable with Java.
Does Groovy support multiple inheritance?
No, Groovy supports single class inheritance only – a class can extend one parent class using extends. However, Groovy provides traits as an alternative to multiple inheritance. Traits let you define reusable behaviors with both method implementations and state, and a class can implement multiple traits. This gives you the benefits of multiple inheritance without the diamond problem. A class can also implement multiple interfaces.
How do Groovy properties work compared to Java fields?
In Groovy, when you declare a field without an access modifier (e.g., String name), it becomes a property. Groovy automatically generates a private backing field, a public getter (getName()), and a public setter (setName()). When you write obj.name, Groovy calls obj.getName(). When you write obj.name = 'Alice', it calls obj.setName('Alice'). You can override these generated methods to add validation or transformation. Fields declared with private do not get auto-generated accessors.
When should I use an abstract class vs an interface in Groovy?
Use an abstract class when you need shared state (fields) and partial implementation (some concrete methods alongside abstract ones). Use an interface when you need a pure contract with no state and no implementation. Since Groovy supports traits (which can have both state and implementation), a good rule of thumb is: use interfaces for type contracts, abstract classes when you need a common constructor or shared fields, and traits when you need reusable behavior that can be mixed into unrelated class hierarchies.
Are private fields truly private in Groovy?
At the bytecode level, Groovy’s private fields are marked private. However, Groovy’s Meta-Object Protocol (MOP) allows runtime access to private members through dynamic dispatch. This means obj.privateField may work at runtime even though it shouldn’t. To enforce strict Java-style access control, annotate your class with @CompileStatic – this disables dynamic dispatch and enforces visibility rules at compile time. For most projects, treating private as a design convention rather than a hard barrier works well.
Related Posts
Previous in Series: Groovy Exception Handling – Complete Guide
Next in Series: Groovy Interfaces – Defining Contracts in Groovy
Related Topics You Might Like:
- Groovy Traits – Reusable Behavior Composition
- Groovy Enums – Type-Safe Constants
- Groovy @Immutable – Immutable Data Classes
This post is part of the Groovy Cookbook series on TechnoScripts.com

No comment