magento2 – Timezone conversion via MagentoFrameworkStdlibDateTime is broken in M2 (CE) – best practice?

Preface: I tested this in CE 2.3.6, but according to Github the relevant code is unchanged since years.

Expected behaviour in M1

Consider this code and its output in M1 (1.9.4.5):

$coreDate = Mage::getSingleton('core/date');
echo
    "gmtTimestamp()t" . $coreDate->gmtTimestamp() . "n" .
    "timestamp()t" . $coreDate->timestamp() . "n" .
    "gmtDate()t" . $coreDate->gmtDate() . "n" .
    "date()tt" . $coreDate->date() . "n";

Output:

gmtTimestamp()  1609489364
timestamp()     1609492964
gmtDate()       2021-01-01 08:22:44
date()          2021-01-01 09:22:44

As you see, it returns the time in GMT/UTC and the local timezone (Europe/Berlin, UTC+1) as expected.

Wrong behaviour in CE 2.3.6

Now have a look at the equivalent (?) code in CE 2.3.6:

$this->formatOutput('DateTime->date()', $dateTime->date());
$this->formatOutput('DateTime->gmtDate()', $dateTime->gmtDate());

Output:

DateTime->date()      2021-01-01 08:36:33
DateTime->gmtDate()   2021-01-01 08:36:33

As you see, there is no difference between date / gmtDate or timestamp/gmtTimestamp.
Calling date_default_timezone_set('Europe/Berlin') prior to the test changes the last four lines to the local time, including gmtDate().

Long version with debug info:

$this->formatOutput('$dateTime', get_class($dateTime));
$this->formatOutput('$timeZone', get_class($timeZone));
$this->formatOutput('date.timezone', ini_get('date.timezone'));
$this->formatOutput('date.timezone', ini_get('date.timezone'));
$this->formatOutput('date_default_timezone_get()', date_default_timezone_get());
$this->formatOutput('TimeZone->getDefaultTimezone()', $timeZone->getDefaultTimezone());
$this->formatOutput('TimeZone->getConfigTimezone()', $timeZone->getConfigTimezone());
$this->formatOutput('DateTime->getGmtOffset()', $dateTime->getGmtOffset());
$this->formatOutput('DateTime->timestamp()', $dateTime->timestamp());
$this->formatOutput('DateTime->gmtTimestamp()', $dateTime->gmtTimestamp());
$this->formatOutput('DateTime->date()', $dateTime->date());
$this->formatOutput('DateTime->gmtDate()', $dateTime->gmtDate());
$this->formatOutput('DateTime->date("Y-m-d H:i:s", time())', $dateTime->date('Y-m-d H:i:s', time()));
$this->formatOutput('DateTime->gmtDate("Y-m-d H:i:s", time())', $dateTime->gmtDate('Y-m-d H:i:s', time()));

Output:

$dateTime                                    MagentoFrameworkStdlibDateTimeDateTime
$timeZone                                    MagentoFrameworkStdlibDateTimeTimezone
date.timezone                                Europe/Berlin
date.timezone                                Europe/Berlin
date_default_timezone_get()                  UTC
TimeZone->getDefaultTimezone()               UTC
TimeZone->getConfigTimezone()                Europe/Berlin
DateTime->getGmtOffset()                     3600
DateTime->timestamp()                        1609490193
DateTime->gmtTimestamp()                     1609490193
DateTime->date()                             2021-01-01 08:36:33
DateTime->gmtDate()                          2021-01-01 08:36:33
DateTime->date("Y-m-d H:i:s", time())        2021-01-01 08:36:33
DateTime->gmtDate("Y-m-d H:i:s", time())     2021-01-01 08:36:33

Reason for the wrong behaviour

This is the method MagentoFrameworkStdlibDateTimeDateTime->date():

/**
 * Converts input date into date with timezone offset
 * Input date must be in GMT timezone
 *
 * @param  string $format
 * @param  int|string $input date in GMT timezone
 * @return string
 */
public function date($format = null, $input = null)
{
    if ($format === null) {
        $format = 'Y-m-d H:i:s';
    }
    $result = date($format, $this->timestamp($input));
    return $result;
}

The PhpDoc clearly states it should work like I expected it to.
The handling of timezones is expected to be handled by the method timestamp():

/**
 * Converts input date into timestamp with timezone offset
 * Input date must be in GMT timezone
 *
 * @param  int|string $input date in GMT timezone
 * @return int
 */
public function timestamp($input = null)
{
    switch (true) {
        case ($input === null):
            $result = $this->gmtTimestamp();
            break;
        case (is_numeric($input)):
            $result = $input;
            break;
        case ($input instanceof DateTimeInterface):
            $result = $input->getTimestamp();
            break;
        default:
            $result = strtotime($input);
    }

    $date = $this->_localeDate->date($result);

    return $date->getTimestamp();
}

Again, the PhpDoc confirms the expected behaviour.
The handling of timezones is passed further on to $this->_localeDate->date(), which creates a DateTime object with the given timezone and then sets the given UTC timestamp (see the last line):
MagentoFrameworkStdlibDateTimeTimeZone->date()

/**
 * @inheritdoc
 */
public function date($date = null, $locale = null, $useTimezone = true, $includeTime = true)
{
    $locale = $locale ?: $this->_localeResolver->getLocale();
    $timezone = $useTimezone
        ? $this->getConfigTimezone()
        : date_default_timezone_get();

    switch (true) {
        case (empty($date)):
            return new DateTime('now', new DateTimeZone($timezone));
        case ($date instanceof DateTime):
            return $date->setTimezone(new DateTimeZone($timezone));
        case ($date instanceof DateTimeImmutable):
            return new DateTime($date->format('Y-m-d H:i:s'), $date->getTimezone());
        case (!is_numeric($date)):
            $timeType = $includeTime ? IntlDateFormatter::SHORT : IntlDateFormatter::NONE;
            $formatter = new IntlDateFormatter(
                $locale,
                IntlDateFormatter::SHORT,
                $timeType,
                new DateTimeZone($timezone)
            );

            $date = $this->appendTimeIfNeeded($date, $includeTime);
            $date = $formatter->parse($date) ?: (new DateTime($date))->getTimestamp();
            break;
    }

    return (new DateTime(null, new DateTimeZone($timezone)))->setTimestamp($date);
}

So this is basically what happens:

/** @var int $utcTimestamp */
$datetime = new DateTime(null, new DateTimeZone('Europe/Berlin'));
$dateTime->setTimestamp($utcTimestamp);
$localTimestamp = $datetime->getTimestamp();

Of course, that can’t work.

Question

Is it recommended to handle the conversion oneself by adding and substracting $dateTime->getGmtOffset() and then using the standard date()?
It’s possible to use $timeZone->date()->format(), but that would only work in one direction, UTC → local.
What are the best practices for time zone handling in M2?