A few weeks ago, I wrote about how to use the modulino pattern in Perl to create unit-testable command line tools. Fellow Houston Perl Monger Brett Estrade pointed me to a different approach on the Perl Applications & Algorithms Discord. This approach trims boilerplate while keeping scripts testable.
Brett’s Utils::H2O::More module amends the lightweight class builder Utils::H2O. It adds many extra methods, including command line argument processing via the Perl-packaged Getopt::Long module. It also promises to build its accessors with less ceremony and code than Moo.
So let’s dive in!
A simple script
A script can use Util::H2O::More’s Getopt2h2o function to process command line options. It returns an object with accessors for each parameter.
Here’s a simple example, modeled after my earlier modulino exercise:
#!/usr/bin/env perl
use v5.38;
use Util::H2O::More v0.4.2 qw(Getopt2h2o);
# name is a string, water is an array of strings
my $o = Getopt2h2o \@ARGV, {}, qw(
name=s
water=s@
);
die "Missing --name\n" unless $o->name;
printf "Good %s, %s!\n", time_of_day(), $o->name;
if ( defined $o->water and $o->water->@* ) {
say 'What kind of water would you like?';
say "- $_" for $o->water->@*;
}
sub time_of_day {
my %hours = (
5 => 'morning',
12 => 'afternoon',
17 => 'evening',
21 => 'night',
);
for ( sort { $b <=> $a } keys %hours ) {
return $hours{$_} if (localtime)[2] >= $_;
}
return 'night';
}
This is short and readable. I like how Getopt::Long’s quirky parameter parsing syntax is repurposed to create accessors, even for multi-valued options.
You can call this script like so:
./h2options.pl --name Aquarius --water hot --water cold
We had to check that --name was set, as Util::H2O::More doesn’t support using an = modifier to specify required options. This is something Getopt::Long allows.
And as Util::H2O’s documentation suggests: “You should probably switch to something like Moo instead [for advanced features].”
But enough about limitations–what if you wanted to use this as a Perl module for testing?
Testing the waters
One of the strengths of a modulino is the ability to unit test its logic without invoking it from the shell. A typical test script looks like this:
#!/usr/bin/env perl
use v5.38;
use Test2::V0;
use modulinh2o;
plan(6);
can_ok(
'modulinh2o',
[ 'time_of_day', 'name', 'water' ],
'class has methods',
);
my $water = modulinh2o->new( name => 'Aquarius' );
isa_ok( $water, ['modulinh2o'],
'object is expected class' );
can_ok(
$water,
[ 'time_of_day', 'name', 'water' ],
'object has methods',
);
is( $water->name, 'Aquarius', 'name set' );
is( $water->name('Bob'), 'Bob', 'name change' );
is( $water->time_of_day,
in_set( qw(
morning
afternoon
evening
night
) ),
'time of day function',
);
It isn’t difficult to adapt a modulino from our earlier simple script:
#!/usr/bin/env perl
use v5.38;
package modulinh2o;
use Getopt::Long qw();
use Util::H2O::More v0.4.2 qw(h2o opt2h2o);
my @opt_spec = qw(
name=s
water=s@
);
my $o = h2o -classify => __PACKAGE__, {},
opt2h2o(@opt_spec);
sub time_of_day {
my %hours = (
5 => 'morning',
12 => 'afternoon',
17 => 'evening',
21 => 'night',
);
for ( sort { $b <=> $a } keys %hours ) {
return $hours{$_} if (localtime)[2] >= $_;
}
return 'night';
}
# constructor that parses arguments w/ basic validation
sub new_with_options ($class) {
Getopt::Long::GetOptionsFromArray(
\@ARGV, $o, @opt_spec
) or die "bad options\n";
die "Missing --name\n" unless $o->name;
return $o;
}
sub run ($self) {
printf "Good %s, %s!\n",
time_of_day(), $self->name;
if ( defined $self->water and $self->water->@* ) {
say 'What kind of water would you like?';
say "- $_" for $self->water->@*;
}
return;
}
package main;
main() unless caller;
sub main { modulinh2o->new_with_options->run() }
And run:
./modulinh2o.pm --name Aquarius \
--water sparkling --water still
This is much shorter than the Moo-based modulino from three weeks ago, but it also doesn’t do as much. There’s no support for using comma separators to pass multiple values to a single argument. Worse, there’s no automatic help text if one passes the wrong options.
Both are fixable, as we’ll see in a moment. Still, you end up having to write the POD yourself, printed out with various invocations of Pod::Usage’s pod2usage() function.
An ounce of script is worth a gallon of documentation
Here’s a full example that adds both --help and --man command line options, as typically provided by traditional Getopt::Long-based scripts:
#!/usr/bin/env perl
use v5.38;
package modulinh2o2;
use Getopt::Long qw();
use Util::H2O::More v0.4.2 qw(h2o opt2h2o);
use Pod::Usage;
my @opt_spec = qw(
name=s
water=s@
help
man
);
my $o = h2o -classify => __PACKAGE__, {},
opt2h2o(@opt_spec);
sub time_of_day {
my %hours = (
5 => 'morning',
12 => 'afternoon',
17 => 'evening',
21 => 'night',
);
for ( sort { $b <=> $a } keys %hours ) {
return $hours{$_} if (localtime)[2] >= $_;
}
return 'night';
}
# different parameter mixes for pod2usage()
my %pod2usage_opt = (
cmdline => {
-exitval => 2,
-verbose => 99,
-sections => 'USAGE/Command line',
-message => 'Use --help to list options',
},
opts => {
-exitval => 0,
-verbose => 99,
-sections => ['USAGE/Command line', 'OPTIONS'],
},
man => {
-exitval => 0,
-verbose => 2,
},
);
sub new_with_options ($class) {
Getopt::Long::GetOptionsFromArray(
\@ARGV, $o, @opt_spec
) or pod2usage( %pod2usage_opt{cmdline} );
pod2usage( $pod2usage_opt{opts} ) if $o->help;
pod2usage( $pod2usage_opt{man} ) if $o->man;
pod2usage( %pod2usage_opt{cmdline},
-message => 'Missing --name',
) unless $o->name;
# default values for the water parameter
$o->water(
( defined $o->water and $o->water->@* )
? [ split /,/, join q{,}, $o->water->@* ]
: [ qw(
still
sparkling
tap
) ] );
return $o;
}
sub run ($self) {
printf "Good %s, %s!\n",
time_of_day(), $self->name;
say 'What kind of water would you like?';
say "- $_" for $self->water->@*;
return;
}
package main;
main() unless caller;
sub main { modulinh2o2->new_with_options->run() }
# the rest below is documentation
__END__
=head1 NAME
modulinh2o2 - demo of a modulino using Util::H2O::More
=head1 USAGE
=head2 Command line
modulinh2o2.pm [options]
=head2 Perl
use modulinh2o2;
my $water = modulinh2o2->new(
name => 'Aquarius',
water => [ qw(
sparkling
still
tap
) ],
);
$water->run;
=head1 OPTIONS
=over
=item B<--name>
Your name here! (required)
=item B<--water>
Type of water to serve. May be specified multiple times, either by repeating the option or separated by commas.
Takes an arrayref when used as a method or construction parameter.
Default values:
=over
=item still
=item sparkling
=item tap
=back
=item B<--help>
Displays a brief help message.
=item B<--man>
Display full documentation as a manual page.
=back
=head1 DESCRIPTION
A sample L<Util::H2O::More> modulino that prints your name, what part of the day it is, and a menu of water choices.
=head1 METHODS
=head2 new
Constructor that takes the above L</OPTIONS> but without the preceding C<-->.
=head2 new_with_options
Alternate constructor that receives its parameters from C<@ARGV>.
=head2 run
Prints out a greeting along with a selection of refreshing drinks.
=head2 ACCESSOR OPTIONS
All of the L</OPTIONS> above are also available as method accessors but without the preceding C<-->.
=head1 FUNCTIONS
=head2 time_of_day
Returns the period of the day based on local time. Possible values are:
=over
=item morning
=item afternoon
=item evening
=item night
=back
=head1 AUTHOR
Mark Gardner <[email protected]>
=head1 LICENSE AND COPYRIGHT
This software is copyright (c) 2025 by Mark Gardner.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.
Now we can run either:
./modulinh2o2.pm --help
to get a quick help message, or:
./modulinh2o2.pm --man
to show the full manual page.
Yes, over half of the line count is documentation. Even granting this the code amount is now comparable to the Moo-based version. And you don’t get the advantage of MooX::Options’ declarative syntax.
To summarize, here’s a table charting the evolution of our modulinh2o:
| Stage | Pros | Cons | Complexity |
|---|---|---|---|
Simple script:Getopt2h2o only | * Minimal code footprint * Instant accessors from CLI arguments * Multi-valued options via @ syntax | * No required-argument enforcement * No auto-help * Manual validation | Low: about 20 lines, one file |
Basic modulino:h2o + opt2h2o | * Testable as a module * Reusable new_with_options constructor* Keeps brevity vs. Moo | * Still no comma-separated multi-values * No auto-help on bad arguments | Medium: adds package structure, test harness |
| Full modulino w/help & man: Pod::Usage integrated | * Support for --help and --man* Comma-separated multi-values handled * Defaults for missing --water* Clearer on-boarding for new users | * More boilerplate * Over half the file is POD * Still less declarative than MooX::Options | High: more moving parts, but user-friendly |
Seen together, these stages trace a course from a bare-bones script to a well-provisioned modulino. Each step adds features at the cost of a little more complexity.
In the end, Util::H2O::More delivers a lean, testable modulino with far less boilerplate than Moo–but also fewer built‑in niceties. If you value speed from idea to working script and can live without declarative option handling, it’s a compelling choice. For more structured needs, MooX::Options still has the edge. Either way, the path from script to production-ready modulino is shorter than you think.
The best tool is the one that flows from idea to “it works” in a single pour.






