Migrating from classical to modern#

If you are maintaining a bless-based codebase and wondering what the equivalent class block looks like, this chapter is the recipe. The translation is mostly mechanical, but the places where it is not mechanical are where real design decisions surface — and where the migration pays for itself.

Before starting, read the modern class chapter for the syntax you are translating into, and skim the classical OO chapter for any constructs the existing code uses that you may not remember in detail.

When to migrate#

Migration is not free. Do it when you get real value:

  • The class is under active development. You are about to add fields, add methods, or change the constructor contract. Doing that work against the class feature is less error-prone than doing it against hand-rolled hash slots.

  • The class has unclear encapsulation. Callers reach into $obj->{fieldname} directly, and you want to stop that without rewriting every call site at once. The class feature makes the violation a compile-time error the moment you switch.

  • The class sits on top of Moose or Moo, and you are paying the load-time cost without using the dependency-heavy features. The modern class is a straightforward replacement for most Moose/Moo classes that only use has and with.

Do not migrate a class that is working and frozen. A stable classical class that will not see new code for another five years is exactly as good as its modern equivalent, and the migration itself will introduce bugs that the stable class does not have.

The mechanical translation#

Take this classical class:

package Counter;

sub new {
    my ($class, %args) = @_;
    my $self = {
        value => $args{value} // 0,
        step  => $args{step}  // 1,
    };
    die "step must be positive" if $self->{step} <= 0;
    return bless $self, $class;
}

sub value {
    my $self = shift;
    $self->{value} = shift if @_;
    return $self->{value};
}

sub step { $_[0]{step} }

sub tick {
    my $self = shift;
    $self->{value} += $self->{step};
}

1;

The modern form:

use v5.38;
use feature 'class';
no warnings 'experimental::class';

class Counter {
    field $value :param :reader :writer = 0;
    field $step  :param :reader         = 1;

    ADJUST {
        die "step must be positive" if $step <= 0;
    }

    method tick { $value += $step }
}

1;

The mapping, piece by piece:

Classical construct

Modern construct

package Counter;

class Counter { ... }

sub new { ... bless ... }

(deleted — generated)

Constructor argument defaulting with //

= DEFAULT or :param ... = DEFAULT

$args{foo} stored in $self->{foo}

field $foo :param

Read-only accessor

:reader

Read/write accessor

:reader :writer

Validation at the end of new

ADJUST block

$self->{foo} inside a method

$foo (the field name)

my $self = shift

(deleted — implicit)

use parent 'Base'; / our @ISA = ('Base')

class Sub :isa(Base) { ... }

$self->SUPER::foo(@args)

unchanged — still works

The non-mechanical parts#

Everything above is rote translation. The places where you need to think are all variants of “the classical code was doing something the class feature does not do the same way.”

Multiple inheritance#

class does not support :isa(A, B). If the classical class has @ISA = ('Parent1', 'Parent2'), you have three options:

  1. Collapse to single inheritance. Usually the second parent is a mixin or role in disguise. Migrate it into a role and consume it with with.

  2. Collapse to composition. If the second parent is a collaborator rather than an ancestor, migrate to delegation instead of inheritance.

  3. Do not migrate the class. If the multiple inheritance is genuinely load-bearing and neither of the above fits, the class is a poor fit for the modern feature. Leave it classical until the core role feature lands.

In practice option 1 or 2 applies to about 95% of the multiple- inheritance cases in real code.

Direct hash access from callers#

If external callers do $counter->{value} rather than $counter->value, migrating the class will break them. The compiler error is clean and local — the error message names the site — but the migration is no longer a drop-in.

Mitigation strategy:

  1. Audit first. Grep for $obj_name->{ patterns across the codebase. The count is usually small.

  2. Add an accessor to the classical class that covers every accessed field.

  3. Replace $obj->{fieldname} call sites with $obj->fieldname() in a dedicated commit.

  4. Then migrate the class to modern form.

Splitting the refactor into two phases keeps each commit small and bisectable.

AUTOLOAD#

AUTOLOAD in a classical class has no modern equivalent. The class feature does not let you catch missing method calls.

If the AUTOLOAD is doing one of the common patterns, translate it:

  • Lazy accessor synthesis — declare the accessors explicitly as fields with :reader or :writer. The class feature’s code generation replaces what AUTOLOAD was doing.

  • Delegation to a contained object — replace with explicit forwarder methods (see roles and delegation) or with a Moose-style handles accessor if the codebase can depend on Moose.

  • A generic proxy — keep the class classical. The class feature is not the right tool.

DESTROY#

class does not introduce a first-class destructor form. If the classical class has a DESTROY, write it as a plain method inside the class block — it is invoked through the same refcount hook:

class Logger {
    field $fh;

    ADJUST { ... }

    sub DESTROY {
        my $self = shift;
        close $self->{fh} if $self->{fh};
    }
}

Note the wart: inside DESTROY you cannot name the field by its declared name, because sub bodies do not see field bindings. Either use a regular method that DESTROY delegates to, or accept the slight asymmetry.

A cleaner pattern when the lifetime is under your control is to add an explicit close method and call it deterministically, leaving DESTROY as a belt-and-braces backup.

Non-hash instances#

If the classical class does bless \@self, $class or bless \$self, $class — an array-backed or scalar-backed instance — the modern feature has no equivalent. Hash-like instance layout is baked in. Keep these classes classical.

Tied hashes / overloading#

use overload and tie integrations work unchanged in a class block. Nothing special to do.

Migrating Moose / Moo classes#

The translation from Moose to class is cleaner than from classical because Moose is already declarative. The rough map:

Moose / Moo

Modern class

use Moose; / use Moo;

use feature 'class'; plus block

has foo => (is => 'ro', ...)

field $foo :param :reader

has foo => (is => 'rw', ...)

field $foo :param :reader :writer

has foo => (default => sub { ... })

field $foo = EXPR (or ADJUST)

extends 'Parent'

class Sub :isa(Parent) { ... }

BUILD { ... }

ADJUST { ... }

with 'Role::Name'

(wait for core roles)

has foo => (isa => 'Int', ...)

(no direct equivalent)

method_modifiers (before/after/around)

(no direct equivalent)

The gap items — type constraints, method modifiers, roles — are what keeps some Moose codebases on Moose for now. If your Moose use is limited to has, extends, and BUILD, the migration is straightforward. If it depends heavily on Moose’s type system or method modifiers, the migration is a project, not a commit.

Testing the migration#

A class migration that changes no external behaviour should pass the existing test suite unchanged. Steps:

  1. Ensure the class has tests covering its public API. If it does not, write them before migrating — the tests are your migration safety net.

  2. Migrate the class.

  3. Run the tests. A clean pass is the completion signal.

  4. Grep the codebase for $obj->{...} reaches into the migrated class’s instances. Fix any hits.

  5. Remove any now-dead use parent, use base, explicit sub new, or accessor boilerplate the old form needed.

If step 3 fails, the failures usually fall into a short list:

  • Direct hash access in a method that the old form tolerated and the new form rejects. Replace with field references.

  • A constructor argument that was silently accepted. The generated constructor is strict about :param names. Either declare the parameter or change the call site.

  • A field initialiser that referenced $self. Field initialisers run before $self is bound. Move the dependent logic into an ADJUST block.

What you get out of it#

The classical-to-modern migration is unusual in that the result is both shorter and more correct:

  • Shorter. The Counter example above drops from ~20 lines to ~10, and the removed lines are all boilerplate.

  • Private fields. External code can no longer reach in. The encapsulation you used to defend in review is now defended by the compiler.

  • No forgotten bless. The generated constructor is always inheritance-safe; you cannot ship a constructor that accidentally blesses into the wrong class.

  • No forgotten $self = shift. The implicit bind cannot go missing.

  • A named feature flag. no warnings 'experimental::class' documents at the top of the file what the class depends on.

Further reading#

  • Modern classes — reference for the syntax you are migrating into.

  • Classical OO — reference for the syntax you are migrating out of.

  • Inheritance and method resolution — the mixed- hierarchy chapter, useful while the codebase is half-migrated.

  • Roles and delegation — where multiple inheritance ends up in the migrated code.

  • class, field, method — the reference pages for the three keywords at the heart of the migration.

  • bless, ref, isa — the primitives that underpin both old and new forms and that keep working across mixed hierarchies.