Groovy Classes and Inheritance – Complete OOP Guide with 12 Examples

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.

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.* and java.io.* are imported automatically
  • Map-based constructors – create objects using named parameters without writing a single constructor
  • Optional typing – use def for dynamic typing or explicit types for compile-time safety
  • Default public visibility – classes and methods are public by default (no need to type public everywhere)

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 main method 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 @PackageScope annotation 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 @Immutable for value objects – When a class just holds data and shouldn’t change after creation, mark it @Immutable. Groovy generates equals(), hashCode(), and toString() for free.
  • Override toString() – Always provide a meaningful toString(). 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 def is 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 @Override annotation – Even though Groovy doesn’t require it, @Override catches 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 @CompileStatic for strict encapsulation – If you want Java-style access modifier enforcement, annotate your class with @CompileStatic.
  • Prefer private for internal state – Mark fields private and 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 @ToString and @EqualsAndHashCode – Groovy provides AST transformation annotations that generate these methods automatically. Use @ToString(includeNames=true) for readable debug output and @EqualsAndHashCode when 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 @MapConstructor or @TupleConstructor to get them back.
  • Confusing property access with field accessthis.name inside a class accesses the field directly, while name without this may 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 @CompileStatic in 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.name calls obj.getName() behind the scenes
  • Constructors come in three flavors: default, custom, and map-based (use @MapConstructor/@TupleConstructor annotations)
  • Inheritance uses extends for single-class inheritance, super to 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 @CompileStatic when 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.

Previous in Series: Groovy Exception Handling – Complete Guide

Next in Series: Groovy Interfaces – Defining Contracts in Groovy

Related Topics You Might Like:

This post is part of the Groovy Cookbook series on TechnoScripts.com

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *