Java 25 Flexible Constructor Bodies

1. Introduction

For over 25 years, Java developers have lived with one of the most annoying rules in the language: the first statement in a constructor must be super() or this(). No exceptions. No validation before calling the superclass. No computation to prepare arguments. If your subclass constructor needed to check that an argument was valid before passing it up, you could not do it directly — you had to resort to ugly workarounds like static helper methods, ternary operator abuse, or factory method patterns.

Think of it this way: imagine you are renovating a house, and the building code says you must pour the foundation before you can even inspect the land. You cannot test the soil, check the property lines, or measure the slope first. You just have to hope everything is fine and deal with problems after the concrete is set. That is what Java constructors felt like before this change.

Here is the kind of code that drove developers crazy:

public class PositiveBigInteger extends BigInteger {

    public PositiveBigInteger(long value) {
        // I want to validate that value > 0 BEFORE calling super()
        // But the compiler says: "Call to 'super()' must be first statement"
        super(Long.toString(value));  // Forced to call super first

        // Now I can validate, but the superclass already did all its work
        // with potentially invalid data
        if (value <= 0) {
            throw new IllegalArgumentException("non-positive value");
        }
    }
}

The superclass constructor runs, allocates resources, possibly writes to files or databases -- all with an invalid argument. Then you throw an exception. The damage is done. This pattern is wasteful, error-prone, and fundamentally backwards.

Java 25 fixes this with Flexible Constructor Bodies (JEP 513). You can now write statements before the call to super() or this(). Validation, argument transformation, field initialization -- all of it can happen in a "prologue" section before the constructor delegation. The feature was previewed in Java 22 (JEP 447), Java 23 (JEP 482), and Java 24 (JEP 492), and is finalized as a permanent feature in Java 25 LTS.

2. What Changed

The rule is simple: you can now place statements before the explicit constructor invocation (super(...) or this(...)). These statements form what the JLS calls the prologue of the constructor. The statements after the constructor invocation form the epilogue.

public class Employee extends Person {

    public Employee(String name, int age) {
        // === PROLOGUE (new in Java 25) ===
        // Validate arguments before calling super
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("Name cannot be blank");
        }
        if (age < 18 || age > 67) {
            throw new IllegalArgumentException("Age must be between 18 and 67");
        }

        // Compute values to pass to super
        String normalizedName = name.trim().toUpperCase();

        // === CONSTRUCTOR INVOCATION ===
        super(normalizedName, age);

        // === EPILOGUE (same as before) ===
        // Full access to this, fields, methods
        this.startDate = LocalDate.now();
        log("Employee created: " + this.getName());
    }
}

The constructor body is now divided into two phases:

Phase Location What You Can Do What You Cannot Do
Prologue Before super() / this() Validate arguments, compute values, declare local variables, throw exceptions, initialize uninitialized fields Access this (methods, read fields), access super (fields, methods), create inner class instances
Epilogue After super() / this() Everything -- full access to this, fields, methods, superclass members Nothing restricted (same as traditional constructor body)

The key insight is that the object is in an early construction context during the prologue. The superclass has not been initialized yet, so the object is not fully formed. Java protects you from accessing the uninitialized object by restricting what you can do in the prologue. But it gives you enough freedom to validate, compute, and prepare -- which is all you typically need.

3. Validation Before super()

The most common use case for flexible constructor bodies is argument validation. You want to fail fast -- reject bad input before the superclass constructor does any work. This is especially important when the superclass constructor is expensive (allocates resources, opens connections, writes to disk).

Example: Validate Before BigInteger Construction

public class PositiveBigInteger extends BigInteger {

    public PositiveBigInteger(long value) {
        // Validate BEFORE superclass does any work
        if (value <= 0) {
            throw new IllegalArgumentException("Value must be positive: " + value);
        }
        super(Long.toString(value));
    }
}

// Usage:
var num = new PositiveBigInteger(42);   // Works fine
var bad = new PositiveBigInteger(-5);   // Throws immediately, no wasted work

Example: Multiple Validation Checks

public class DatabaseConnection extends AbstractConnection {
    private final String schema;

    public DatabaseConnection(String host, int port, String schema, String user) {
        // Validate all arguments before superclass initializes the connection
        Objects.requireNonNull(host, "Host cannot be null");
        Objects.requireNonNull(schema, "Schema cannot be null");
        Objects.requireNonNull(user, "User cannot be null");

        if (host.isBlank()) {
            throw new IllegalArgumentException("Host cannot be blank");
        }
        if (port < 1 || port > 65535) {
            throw new IllegalArgumentException("Port must be between 1 and 65535: " + port);
        }
        if (!schema.matches("[a-zA-Z_][a-zA-Z0-9_]*")) {
            throw new IllegalArgumentException("Invalid schema name: " + schema);
        }

        // All validated -- safe to proceed with connection setup
        super(host, port, user);

        // Now we can initialize our own fields
        this.schema = schema;
    }
}

Example: Validate with Complex Business Rules

public class Order extends BaseEntity {

    public Order(List items, Customer customer, LocalDate deliveryDate) {
        // Business rule validation in the prologue
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException("Order must have at least one item");
        }
        if (customer.isSuspended()) {
            throw new IllegalStateException("Cannot create order for suspended customer: "
                + customer.getId());
        }
        if (deliveryDate.isBefore(LocalDate.now().plusDays(1))) {
            throw new IllegalArgumentException("Delivery date must be at least tomorrow");
        }

        double total = items.stream()
            .mapToDouble(item -> item.price() * item.quantity())
            .sum();

        if (total > customer.getCreditLimit()) {
            throw new IllegalStateException(
                "Order total $%.2f exceeds credit limit $%.2f".formatted(
                    total, customer.getCreditLimit()));
        }

        // All business rules passed -- initialize the entity
        super(UUID.randomUUID(), LocalDateTime.now());
    }
}

4. Computation Before super()

Sometimes the superclass constructor expects arguments that require transformation or computation from the subclass's raw inputs. Before Java 25, this often forced you into contortions with static helper methods or inline ternary expressions.

Example: Transform Arguments for Superclass

public class HttpEndpoint extends Endpoint {

    public HttpEndpoint(String rawUrl) {
        // Parse and normalize the URL before passing to superclass
        String url = rawUrl.trim().toLowerCase();
        if (!url.startsWith("http://") && !url.startsWith("https://")) {
            url = "https://" + url;
        }

        URI uri = URI.create(url);
        String host = uri.getHost();
        int port = uri.getPort() == -1 ? 443 : uri.getPort();
        String path = uri.getPath().isEmpty() ? "/" : uri.getPath();

        super(host, port, path);
    }
}

Example: Compute Derived Values

public class Circle extends Shape {

    public Circle(double radius) {
        if (radius <= 0) {
            throw new IllegalArgumentException("Radius must be positive: " + radius);
        }

        // Compute area and circumference to pass to superclass
        double area = Math.PI * radius * radius;
        double circumference = 2 * Math.PI * radius;

        super("Circle", area, circumference);
    }
}

public class Rectangle extends Shape {

    public Rectangle(double width, double height) {
        if (width <= 0 || height <= 0) {
            throw new IllegalArgumentException(
                "Dimensions must be positive: %s x %s".formatted(width, height));
        }

        double area = width * height;
        double perimeter = 2 * (width + height);

        super("Rectangle", area, perimeter);
    }
}

Example: Read Configuration Before Initializing

public class ConfigurableService extends ManagedService {

    public ConfigurableService(Path configPath) {
        // Read and parse configuration before superclass init
        Properties props;
        try {
            props = new Properties();
            props.load(Files.newInputStream(configPath));
        } catch (IOException e) {
            throw new UncheckedIOException("Cannot read config: " + configPath, e);
        }

        String serviceName = props.getProperty("service.name", "default");
        int threadPoolSize = Integer.parseInt(
            props.getProperty("thread.pool.size", "10"));
        Duration timeout = Duration.parse(
            props.getProperty("timeout", "PT30S"));

        super(serviceName, threadPoolSize, timeout);
    }
}

5. Restrictions

The prologue is not a free-for-all. Java imposes strict rules to prevent you from accessing an uninitialized object, which would lead to subtle bugs and security vulnerabilities. Understanding these restrictions is critical to using the feature correctly.

What You CANNOT Do in the Prologue

class Parent {
    int parentField;
    void parentMethod() { System.out.println("parent"); }
}

class Child extends Parent {
    int childField = 10;
    String name;  // No initializer -- can be assigned in prologue

    Child(int value) {
        // CANNOT reference 'this' explicitly
        System.out.println(this);           // COMPILE ERROR
        this.hashCode();                    // COMPILE ERROR

        // CANNOT read instance fields (even uninitialized ones)
        int x = childField;                // COMPILE ERROR
        int y = this.childField;           // COMPILE ERROR

        // CANNOT call instance methods
        toString();                         // COMPILE ERROR
        this.someMethod();                  // COMPILE ERROR

        // CANNOT access superclass members
        int z = super.parentField;          // COMPILE ERROR
        super.parentMethod();               // COMPILE ERROR

        // CANNOT create inner class instances (they capture 'this')
        class Inner {}
        new Inner();                        // COMPILE ERROR

        super(value);
    }

    void someMethod() {}
}

What You CAN Do in the Prologue

class Child extends Parent {
    final int x;       // No initializer
    String label;      // No initializer

    Child(int value, String rawLabel) {
        // CAN declare and use local variables
        int computed = value * 2 + 1;
        String normalized = rawLabel.trim().toLowerCase();

        // CAN call static methods
        Objects.requireNonNull(rawLabel);
        int validated = Math.max(0, value);

        // CAN throw exceptions
        if (value < 0) {
            throw new IllegalArgumentException("negative: " + value);
        }

        // CAN use control flow (if/else, switch, try/catch, loops)
        String prefix;
        switch (value) {
            case 0 -> prefix = "ZERO";
            case 1 -> prefix = "ONE";
            default -> prefix = "OTHER";
        }

        // CAN assign to uninitialized fields (no initializer in declaration)
        this.x = computed;
        this.label = prefix + "_" + normalized;

        // CAN access enclosing instance (if this is a nested class)
        // CAN use constructor parameters
        // CAN create objects that do not reference 'this'

        super(validated);
    }
}

The Field Assignment Rule

One of the most interesting aspects is that you can assign to fields in the prologue, but only to fields that have no initializer in their declaration. You cannot read those fields -- only write to them. This enables a critical pattern: setting a field's value before super() runs, so that if the superclass constructor calls an overridable method, the field is already initialized.

class Super {
    Super() {
        // Superclass constructor calls overridable method
        overriddenMethod();
    }
    void overriddenMethod() {}
}

// BEFORE Java 25: Field is 0 when overriddenMethod() is called
class OldSub extends Super {
    final int x;
    OldSub(int x) {
        super();          // Calls overriddenMethod() -- this.x is still 0!
        this.x = x;       // Too late
    }
    @Override
    void overriddenMethod() {
        System.out.println("x = " + x);  // Prints "x = 0" -- uninitialized!
    }
}

// AFTER Java 25: Field is properly set before super() runs
class NewSub extends Super {
    final int x;
    NewSub(int x) {
        this.x = x;       // Set field BEFORE super()
        super();           // Calls overriddenMethod() -- this.x is already set!
    }
    @Override
    void overriddenMethod() {
        System.out.println("x = " + x);  // Prints "x = 42" -- correct!
    }
}

public static void main(String[] args) {
    new OldSub(42);  // Prints: x = 0
    new NewSub(42);  // Prints: x = 42
}

This is not just a convenience -- it fixes a correctness problem that has plagued Java since its inception. Any time a superclass constructor calls an overridable method, subclass fields are in an uninitialized state. With flexible constructor bodies, you can ensure fields are set before the superclass sees them.

Restrictions Summary Table

Action Allowed in Prologue? Notes
Declare local variables Yes Normal local variable rules apply
Use constructor parameters Yes Full access to all parameters
Call static methods Yes No instance needed
Throw exceptions Yes Fail-fast validation
Control flow (if, switch, loops) Yes Full control flow
Assign to this.field (no initializer) Yes Write-only; cannot read back
Read this.field No Object not initialized yet
Call instance methods No Object not initialized yet
Reference this No Except for field assignment
Access super members No Superclass not initialized yet
Create inner class instances No Inner classes capture this
Assign to field with initializer No Only uninitialized fields

6. Before vs After Comparison

Let us look at five real-world patterns and see how they improve with flexible constructor bodies. In each case, the "before" code uses a workaround, and the "after" code uses the new prologue.

Example 1: Input Validation

// BEFORE: Validation AFTER super() -- too late, superclass already ran
class OldEmployee extends Person {
    OldEmployee(String name, int age) {
        super(name, age);  // Person does work with potentially bad values
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("Invalid age: " + age);
        }
    }
}

// AFTER: Validation BEFORE super() -- fail fast
class NewEmployee extends Person {
    NewEmployee(String name, int age) {
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("Invalid age: " + age);
        }
        Objects.requireNonNull(name, "Name required");
        super(name, age);
    }
}

Example 2: Argument Transformation

// BEFORE: Static helper method workaround
class OldHttpUrl extends Url {
    OldHttpUrl(String rawUrl) {
        super(normalizeUrl(rawUrl));  // Must use static method
    }

    // Forced to write a static helper just to prepare the argument
    private static String normalizeUrl(String raw) {
        String url = raw.trim().toLowerCase();
        if (!url.startsWith("https://")) {
            url = "https://" + url;
        }
        return url;
    }
}

// AFTER: Inline computation in the prologue
class NewHttpUrl extends Url {
    NewHttpUrl(String rawUrl) {
        String url = rawUrl.trim().toLowerCase();
        if (!url.startsWith("https://")) {
            url = "https://" + url;
        }
        super(url);
    }
}

Example 3: Conditional Super Arguments

// BEFORE: Ternary operator abuse for conditional arguments
class OldRetryPolicy extends Policy {
    OldRetryPolicy(int maxRetries, Duration timeout) {
        super(
            maxRetries <= 0 ? 3 : maxRetries,           // Default if invalid
            timeout == null ? Duration.ofSeconds(30) : timeout,  // Default if null
            maxRetries > 10 ? "aggressive" : "standard"  // Computed strategy
        );
    }
}

// AFTER: Clear, readable prologue
class NewRetryPolicy extends Policy {
    NewRetryPolicy(int maxRetries, Duration timeout) {
        // Normalize with clear variable names
        int retries = maxRetries <= 0 ? 3 : maxRetries;
        Duration actualTimeout = timeout != null ? timeout : Duration.ofSeconds(30);
        String strategy = retries > 10 ? "aggressive" : "standard";

        super(retries, actualTimeout, strategy);
    }
}

Example 4: Multi-Step Argument Preparation

// BEFORE: Multiple static helpers or deeply nested ternaries
class OldSecureConnection extends Connection {
    OldSecureConnection(String host, Map config) {
        super(
            extractHost(host),
            extractPort(host, config),
            buildSslContext(config)
        );
    }

    private static String extractHost(String host) {
        return host.contains(":") ? host.split(":")[0] : host;
    }

    private static int extractPort(String host, Map config) {
        if (host.contains(":")) {
            return Integer.parseInt(host.split(":")[1]);
        }
        return Integer.parseInt(config.getOrDefault("default.port", "443"));
    }

    private static SSLContext buildSslContext(Map config) {
        try {
            SSLContext ctx = SSLContext.getInstance(
                config.getOrDefault("ssl.protocol", "TLSv1.3"));
            ctx.init(null, null, null);
            return ctx;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

// AFTER: Everything inline in the prologue -- clear flow, no scattered helpers
class NewSecureConnection extends Connection {
    NewSecureConnection(String host, Map config) {
        // Parse host and port
        String actualHost;
        int port;
        if (host.contains(":")) {
            String[] parts = host.split(":");
            actualHost = parts[0];
            port = Integer.parseInt(parts[1]);
        } else {
            actualHost = host;
            port = Integer.parseInt(config.getOrDefault("default.port", "443"));
        }

        // Build SSL context
        SSLContext sslContext;
        try {
            String protocol = config.getOrDefault("ssl.protocol", "TLSv1.3");
            sslContext = SSLContext.getInstance(protocol);
            sslContext.init(null, null, null);
        } catch (Exception e) {
            throw new RuntimeException("Failed to create SSL context", e);
        }

        super(actualHost, port, sslContext);
    }
}

Example 5: Defensive Copy Before Super

// BEFORE: Cannot make defensive copy before super()
class OldImmutableConfig extends Config {
    private final Map properties;

    OldImmutableConfig(Map properties) {
        super(properties);  // Passes mutable reference to super!
        // Defensive copy is too late -- super already has the mutable reference
        this.properties = Map.copyOf(properties);
    }
}

// AFTER: Defensive copy in prologue -- super gets the immutable version
class NewImmutableConfig extends Config {
    private final Map properties;

    NewImmutableConfig(Map properties) {
        // Make defensive copy BEFORE super sees it
        var safeCopy = Map.copyOf(properties);
        super(safeCopy);  // Super gets the immutable copy
        this.properties = safeCopy;
    }
}

7. Old Workarounds Eliminated

Over the years, Java developers developed several workaround patterns to get around the "super must be first" restriction. Every one of these can now be replaced with a simple prologue. Let us inventory these anti-patterns and retire them.

Workaround 1: Static Factory Methods

This was the most common approach -- make the constructor private and provide a static method that does the validation/computation:

// OLD: Static factory workaround
class OldPercentage extends Number {
    private final double value;

    // Private constructor -- no validation here
    private OldPercentage(double validated) {
        this.value = validated;
    }

    // Public factory does the validation
    public static OldPercentage of(double value) {
        if (value < 0 || value > 100) {
            throw new IllegalArgumentException("Not a percentage: " + value);
        }
        return new OldPercentage(value);
    }

    // ... Number abstract methods ...
}

// NEW: Direct constructor with prologue
class NewPercentage extends Number {
    private final double value;

    public NewPercentage(double value) {
        if (value < 0 || value > 100) {
            throw new IllegalArgumentException("Not a percentage: " + value);
        }
        super();
        this.value = value;
    }

    // ... Number abstract methods ...
}

Workaround 2: Static Helper Methods

When the superclass constructor needed computed arguments, developers wrote private static methods just to compute them:

// OLD: Static helper method to compute super() arguments
class OldTimestamp extends Instant {
    OldTimestamp(String isoString) {
        super(parseToEpochSecond(isoString), parseToNano(isoString));
    }

    private static long parseToEpochSecond(String s) {
        return Instant.parse(s).getEpochSecond();
    }

    private static int parseToNano(String s) {
        return Instant.parse(s).getNano();  // Parses TWICE!
    }
}

// NEW: Parse once in the prologue
class NewTimestamp extends Instant {
    NewTimestamp(String isoString) {
        Instant parsed = Instant.parse(isoString);
        super(parsed.getEpochSecond(), parsed.getNano());  // Parse once, use twice
    }
}

Notice how the old version had to parse the string twice because each static method was independent. The prologue lets you parse once and use the result for multiple super() arguments.

Workaround 3: Ternary Operator Abuse

When the computation was "simple enough," developers crammed it into ternary expressions inside the super() call:

// OLD: Unreadable nested ternaries
class OldCacheConfig extends Config {
    OldCacheConfig(String name, int size, boolean isLocal) {
        super(
            name == null ? "default-cache" : name.trim(),
            size <= 0 ? (isLocal ? 1000 : 10000) : Math.min(size, isLocal ? 5000 : 50000),
            isLocal ? Duration.ofMinutes(5) : Duration.ofHours(1),
            name != null && name.startsWith("temp-") ? EvictionPolicy.LRU : EvictionPolicy.LFU
        );  // Good luck debugging this
    }
}

// NEW: Clear, debuggable prologue
class NewCacheConfig extends Config {
    NewCacheConfig(String name, int size, boolean isLocal) {
        String cacheName = (name == null) ? "default-cache" : name.trim();

        int maxAllowed = isLocal ? 5000 : 50000;
        int defaultSize = isLocal ? 1000 : 10000;
        int cacheSize = (size <= 0) ? defaultSize : Math.min(size, maxAllowed);

        Duration ttl = isLocal ? Duration.ofMinutes(5) : Duration.ofHours(1);

        EvictionPolicy policy = (name != null && name.startsWith("temp-"))
            ? EvictionPolicy.LRU
            : EvictionPolicy.LFU;

        super(cacheName, cacheSize, ttl, policy);
    }
}

8. Use Cases

Beyond validation and argument transformation, flexible constructor bodies open up several important patterns that were previously awkward or impossible.

8.1 Builder-to-Constructor Pattern

When a subclass accepts a builder or configuration object and needs to extract specific values for the superclass:

public class RestClient extends HttpClient {

    public RestClient(RestClientConfig config) {
        // Extract and validate in the prologue
        Objects.requireNonNull(config, "Config required");
        String baseUrl = config.getBaseUrl();
        if (baseUrl == null || baseUrl.isBlank()) {
            throw new IllegalArgumentException("Base URL required");
        }

        Duration connectTimeout = config.getConnectTimeout() != null
            ? config.getConnectTimeout()
            : Duration.ofSeconds(10);

        Duration readTimeout = config.getReadTimeout() != null
            ? config.getReadTimeout()
            : Duration.ofSeconds(30);

        int maxConnections = Math.max(1, config.getMaxConnections());

        // Pass extracted values to superclass
        super(baseUrl, connectTimeout, readTimeout, maxConnections);
    }
}

8.2 Defensive Copies

Making defensive copies before the superclass sees the mutable data is a fundamental security and correctness practice:

public class ImmutableMatrix extends Matrix {

    public ImmutableMatrix(double[][] data) {
        // Deep defensive copy in the prologue
        Objects.requireNonNull(data, "Data required");
        if (data.length == 0) {
            throw new IllegalArgumentException("Matrix must have at least one row");
        }

        int cols = data[0].length;
        double[][] copy = new double[data.length][cols];
        for (int i = 0; i < data.length; i++) {
            if (data[i].length != cols) {
                throw new IllegalArgumentException("Jagged arrays not allowed");
            }
            System.arraycopy(data[i], 0, copy[i], 0, cols);
        }

        // Super receives the defensive copy -- original cannot mutate our state
        super(copy, data.length, cols);
    }
}

8.3 Argument Canonicalization

Converting arguments to a canonical form before construction:

public class EmailAddress extends Address {

    public EmailAddress(String email) {
        Objects.requireNonNull(email, "Email required");

        // Canonicalize: trim, lowercase, validate format
        String canonical = email.trim().toLowerCase(Locale.ROOT);

        if (!canonical.matches("^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$")) {
            throw new IllegalArgumentException("Invalid email format: " + email);
        }

        // Split into parts for the superclass
        int atIndex = canonical.indexOf('@');
        String localPart = canonical.substring(0, atIndex);
        String domain = canonical.substring(atIndex + 1);

        super(localPart, domain);
    }
}

8.4 Logging and Auditing

Recording construction events before the object is fully initialized:

public class AuditedTransaction extends Transaction {
    private static final Logger log = LoggerFactory.getLogger(AuditedTransaction.class);

    public AuditedTransaction(BigDecimal amount, String fromAccount, String toAccount) {
        // Log and audit BEFORE the transaction is constructed
        log.info("Creating transaction: {} from {} to {}",
            amount, fromAccount, toAccount);

        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            log.warn("Rejected: non-positive amount {}", amount);
            throw new IllegalArgumentException("Amount must be positive");
        }

        if (amount.compareTo(new BigDecimal("1000000")) > 0) {
            log.warn("Large transaction flagged for review: {}", amount);
            AuditService.flagForReview(fromAccount, toAccount, amount);
        }

        // Generate a unique transaction ID
        String txId = UUID.randomUUID().toString();
        log.debug("Assigned transaction ID: {}", txId);

        super(txId, amount, fromAccount, toAccount);
    }
}

9. With Records

Records introduced in Java 16 have their own constructor conventions, and flexible constructor bodies work with them -- but with specific rules you need to understand.

Record Constructor Types

Constructor Type Description Flexible Bodies?
Canonical constructor Matches all record components No super() or this() to precede -- but can have early field assignments
Compact canonical constructor No parameter list -- parameters are implicit Same as above -- validates/transforms before implicit assignment
Non-canonical constructor Different signature, delegates via this() Yes -- statements before this()

The big win for records is in non-canonical constructors. These must delegate to the canonical constructor via this(), and you can now put validation and computation before that delegation.

Example: Non-Canonical Record Constructor with Prologue

record Point(double x, double y) {

    // Compact canonical constructor -- validates components
    Point {
        if (Double.isNaN(x) || Double.isNaN(y)) {
            throw new IllegalArgumentException("Coordinates cannot be NaN");
        }
    }

    // Non-canonical: construct from polar coordinates
    Point(double radius, double angleRadians, boolean polar) {
        // Prologue: validate and convert polar to cartesian
        if (radius < 0) {
            throw new IllegalArgumentException("Radius cannot be negative: " + radius);
        }
        double cartX = radius * Math.cos(angleRadians);
        double cartY = radius * Math.sin(angleRadians);

        this(cartX, cartY);  // Delegate to canonical constructor
    }

    // Non-canonical: construct from string "x,y"
    Point(String coordinates) {
        // Prologue: parse and validate
        Objects.requireNonNull(coordinates, "Coordinates string required");
        String[] parts = coordinates.split(",");
        if (parts.length != 2) {
            throw new IllegalArgumentException(
                "Expected format 'x,y' but got: " + coordinates);
        }

        double parsedX;
        double parsedY;
        try {
            parsedX = Double.parseDouble(parts[0].trim());
            parsedY = Double.parseDouble(parts[1].trim());
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(
                "Invalid coordinate numbers: " + coordinates, e);
        }

        this(parsedX, parsedY);
    }
}

Example: Record with Complex Non-Canonical Constructor

record DateRange(LocalDate start, LocalDate end) {

    // Canonical validates ordering
    DateRange {
        if (start.isAfter(end)) {
            throw new IllegalArgumentException(
                "Start date %s is after end date %s".formatted(start, end));
        }
    }

    // Construct from a pair of ISO strings
    DateRange(String startStr, String endStr) {
        LocalDate parsedStart = LocalDate.parse(startStr);
        LocalDate parsedEnd = LocalDate.parse(endStr);

        this(parsedStart, parsedEnd);  // Delegates to canonical
    }

    // Construct a range of N days starting from a date
    DateRange(LocalDate start, int days) {
        if (days <= 0) {
            throw new IllegalArgumentException("Days must be positive: " + days);
        }
        LocalDate computedEnd = start.plusDays(days - 1);

        this(start, computedEnd);
    }

    // Construct for "this week" (Monday to Sunday)
    DateRange(int isoWeekNumber, int year) {
        if (isoWeekNumber < 1 || isoWeekNumber > 53) {
            throw new IllegalArgumentException("Invalid week: " + isoWeekNumber);
        }

        LocalDate monday = LocalDate.of(year, 1, 1)
            .with(java.time.temporal.WeekFields.ISO.weekOfYear(), isoWeekNumber)
            .with(java.time.DayOfWeek.MONDAY);

        LocalDate sunday = monday.plusDays(6);

        this(monday, sunday);
    }
}

Example: Record with Defensive Copy in Non-Canonical Constructor

record ImmutablePair(A first, B second) {

    ImmutablePair {
        Objects.requireNonNull(first, "First element cannot be null");
        Objects.requireNonNull(second, "Second element cannot be null");
    }

    // Construct from a Map.Entry with defensive extraction
    ImmutablePair(Map.Entry entry) {
        Objects.requireNonNull(entry, "Entry cannot be null");

        // Extract values in prologue -- entry might be modified concurrently
        A extractedFirst = entry.getKey();
        B extractedSecond = entry.getValue();

        this(extractedFirst, extractedSecond);
    }
}

10. Best Practices

Flexible constructor bodies are a welcome improvement, but like any feature, they can be misused. Here are the guidelines I follow for writing clean, maintainable constructor prologues.

10.1 Keep the Prologue Simple

The prologue should be short, focused, and obvious. If your prologue is longer than 10-15 lines, consider whether some of that logic belongs in a separate method or class.

// GOOD: Short, focused prologue
public class ApiClient extends HttpClient {
    public ApiClient(String baseUrl) {
        Objects.requireNonNull(baseUrl, "Base URL required");
        String normalized = baseUrl.endsWith("/")
            ? baseUrl.substring(0, baseUrl.length() - 1)
            : baseUrl;
        super(URI.create(normalized));
    }
}

// BAD: Prologue doing too much work
public class OverEngineered extends Service {
    public OverEngineered(Path configFile) {
        // 50 lines of config parsing, network calls, database lookups...
        // This belongs in a factory method or builder, not a prologue
        Properties props = new Properties();
        // ... 40 more lines ...
        super(/* many args */);
    }
}

10.2 Validation Patterns

Establish consistent validation patterns across your codebase:

// Pattern 1: Fail-fast with descriptive messages
public class Account extends Entity {
    public Account(String id, BigDecimal balance, String currency) {
        Objects.requireNonNull(id, "Account ID cannot be null");
        Objects.requireNonNull(balance, "Balance cannot be null");
        Objects.requireNonNull(currency, "Currency cannot be null");

        if (id.length() != 10) {
            throw new IllegalArgumentException(
                "Account ID must be 10 characters, got: " + id.length());
        }
        if (balance.signum() < 0) {
            throw new IllegalArgumentException(
                "Initial balance cannot be negative: " + balance);
        }
        if (!Set.of("USD", "EUR", "GBP", "JPY").contains(currency)) {
            throw new IllegalArgumentException(
                "Unsupported currency: " + currency);
        }

        super(id, balance, currency);
    }
}

// Pattern 2: Use Preconditions utility (like Guava)
public class Shipment extends Entity {
    public Shipment(String trackingId, double weight, String destination) {
        Preconditions.checkNotNull(trackingId, "Tracking ID required");
        Preconditions.checkArgument(weight > 0, "Weight must be positive: %s", weight);
        Preconditions.checkArgument(
            destination != null && !destination.isBlank(),
            "Destination required");

        super(trackingId);
    }
}

10.3 When to Use Flexible Constructor Bodies

Use the prologue when:

  • Validation before delegation: You need to reject bad input before the superclass does work
  • Argument transformation: You need to compute values to pass to super()
  • Field initialization for overridable methods: The superclass constructor calls overridable methods and your field must be set first
  • Defensive copies: You need to copy mutable arguments before super() sees them

Do NOT use the prologue for:

  • Complex business logic: If the prologue needs 30+ lines, use a builder or factory method instead
  • I/O operations: Reading files, making network calls, or database queries in a constructor prologue is a code smell. Use a factory method.
  • Side effects: Avoid modifying global state, sending emails, or firing events from the prologue. Constructors should construct, not orchestrate.

10.4 Migration Strategy

If you have existing code with the old workaround patterns, here is how to migrate:

  1. Identify candidates: Search for private static methods called only from constructors -- these are likely argument-preparation helpers
  2. Inline the logic: Move the static method body into the constructor prologue
  3. Delete the helper: Remove the now-unused static method
  4. Test: Ensure behavior is identical. The change should be purely structural.
// Step 1: Identify the pattern
class Before extends Parent {
    Before(String input) {
        super(validate(input), transform(input));  // Static helpers
    }
    private static String validate(String s) {
        if (s == null || s.isBlank()) throw new IllegalArgumentException("blank");
        return s;
    }
    private static int transform(String s) {
        return s.trim().length();
    }
}

// Step 2 & 3: Inline and delete
class After extends Parent {
    After(String input) {
        if (input == null || input.isBlank()) {
            throw new IllegalArgumentException("blank");
        }
        String trimmed = input.trim();
        int length = trimmed.length();
        super(trimmed, length);
    }
    // No more static helpers needed
}

Summary

Flexible Constructor Bodies remove one of Java's oldest and most frustrating restrictions. The ability to write code before super() or this() enables cleaner validation, simpler argument preparation, safer field initialization, and the elimination of static helper method workarounds. The restrictions in the prologue (no this access, no superclass member access) are sensible and prevent the bugs that the old rule was originally trying to avoid. With Java 25, constructor code can finally be written in the order you think about it: validate first, then initialize.




Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

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