DEV Community

Cover image for Refactoring Legacy: Part 1 - DTO's & Value Objects
Paul Clegg
Paul Clegg

Posted on • Edited on • Originally published at clegginabox.co.uk

Refactoring Legacy: Part 1 - DTO's & Value Objects

Ever opened a codebase where a single JSON payload could arrive in 17 different shapes depending on the phase of the moon?

Over the last few years my contracts have involved working with legacy code in one way or another. Outdated software, missing documentation, inconsistent data structures and the occasional big ball of mud.

I originally planned to write a single article summarising the tools, patterns and techniques I've found useful. The more I thought about it - the bigger the article got, the more it started to resemble some of the tangled software I've worked on. Rather than create one sprawling guide, I'm breaking this into smaller, practical articles. Which as it happens, is also a good approach to software.

The current (and probably growing) list includes:

This first article focuses on DTOs and Value Objects. Two deceptively simple tools. Used together they can create predictable and self documenting boundaries.

But before getting into the patterns themselves, I want to outline a few foundational ideas: how I think about legacy, why I structure code the way I do and the principles that guide my approach when dealing not just with legacy, but software as a whole.

Understanding Legacy Code (aka Don't Be a Dick About It)

Every line of code tells a story. The deadline that couldn't move. The hotfix deployed at 3am. The developer who left – or never had the time to refactor.

Look back at your own code from six months ago. How about two years? Five? What would you say about it?

Legacy code can be frustrating. You'll have moments with your head in your hands, screaming "whyyyyy did they do it this way?". If you let that frustration define your approach, you'll make the job much harder than it needs to be.

Empathy is the key.

Instead of assuming all the previous developers were morons, assume they were thoughtful people who did the best they could with what they had. Then ask the better questions.

  • "What problem were they actually solving?"
  • "What constraints shaped this decision?"
  • "What can I learn before I start changing things?"

Sometimes your answers point to business processes that need improving. Sometimes technical decisions shaped by pressures that no longer exist. Sometimes they show you have skills or tools that weren't available at the time.

That's not evidence of incompetence, it's an opportunity to make changes, to learn, to share knowledge and lift the whole team.

Pragmatism Over Perfection

Design patterns are guidelines, not commandments etched into stone. They’re tools, not religion.

This isn't just about legacy code, it's foundational to all software engineering.

You could build the quickest, most secure, easiest-to-use ATM software ever. Perfect architecture, bulletproof security, zero bugs. But if the ATM has no buttons, it's completely useless.

Just because you can doesn't mean you should. Do you really need React and Next.js for a site that's nothing more than a few multi-page forms? Would some HTML and a bit of Javascript achieve the same result with a fraction of the complexity?

I don't believe in absolutes in software engineering. Just like patterns, they never fit neatly into the messy human complexity we're trying to capture in code. Use the things that work for you right now. You don't need the kitchen sink.

DTOs without a full DDD implementation? Pragmatic.

Value Objects without Event Sourcing? Pragmatic.

A Strangler Fig pattern that doesn't wrap the entire legacy system? Still pragmatic.

Skipping most of these patterns entirely for a simple CRUD app? Extremely pragmatic.

Lift your head away from the screen. Why are you writing this? Who is it for? Who needs to maintain it going forward? What problem are you actually solving - not the technical puzzle, but the business need, the user pain point, the thing that matters?

Every abstraction you introduce, every pattern you apply, every comment you write (or don't). It will all become someone else's legacy code eventually.

Pragmatism doesn’t mean abandoning good design. It means understanding why the design exists, when it matters and when real world constraints matter more.

A Case Study

I'll elaborate on this example in later articles, but here's the scenario:

A customer purchases a vehicle insurance policy on an external website. A JSON payload is then POST'ed to an existing legacy endpoint, which:

  • creates or updates a Customer record
  • creates or updates a Vehicle record
  • creates a Policy record
  • creates financial records
  • generates documentation
  • sends the documentation via email
  • creates an invoice in external financial software

The JSON payload matches the format of CSV files which are uploaded and processed by similar legacy code. The refactor needs to replicate this functionality. The payload format isn't ideal, but changing it means touching legacy code that's been stable for years. That's a risk we don't need to take.

The JSON payload looks a bit like this

{
  "title": "Mr",
  "firstname": "Bean",
  "surname": "Bean",
  "qnumber": "WB109301016026",
  "tradename": "",
  "address1": "Flat 2",
  "address2": "12 Arbour Road",
  "town": "HIGHBURY",
  "county": "London",
  "postcode": "N1 4TY",
  "telephone": "07123456789",
  "email": "mr.bean@minicooper.com",
  "vehmake": "RELIANT",
  "vehmodel": "REGAL SUPERVAN III",
  "vehregno": "GRA26K",
  "vehtype": "CAR",
  "vehdatereg": "23/10/1975",
  "vehvin": "BEAN1977TEDDY001",
  "covertype": "D",
  "cover": "ST",
  "pcstart": "01/10/2025",
  "pcend": "10/10/2025",
  "scheme": "vehicle",
  "vehyom": "1975",
  "vehweight": "450",
  "vehcolour": "BLUE",
  "totnet": "25.07",
  "totgross": "28.08",
  "totcomm": "0.00",
  "iptgross": "3.01",
  "vehadded": "30/09/2025",
  "comments": "CustomerReference=TEDDY001;PolicyNumber=WB109301016026;CoverType=Vehicle insurance for three-wheeler incidents"
}
Enter fullscreen mode Exit fullscreen mode

You could ask many questions here. Why this naming scheme? Why is PolicyNumber in the comments the same as qnumber. What is a qnumber? To answer those questions you would need to understand over a decade of business and development decisions. And you'd still be no further along in actually doing something with it.

The pragmatic approach? Accept what we cannot change and protect what we can.

So how do we stop this external data from dictating the shape of our entire application? We build a boundary - and that boundary begins with a Data Transfer Object.

Data Transfer Objects

A first pass at a DTO for this request could look a bit like the below. This example uses Symfony's Serializer. Symfony's libraries can be used standalone outside of the framework. Other serializers are available.

<?php
declare(strict_types=1);

namespace App\Modules\Policy\Application\Dto;

use DateTimeImmutable;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;

final readonly class PolicyPurchasePayload
{
    #[SerializedName('title')]
    public string $title;

    #[SerializedName('firstname')]
    public string $firstName;

    #[SerializedName('surname')]
    public string $surname;

    #[SerializedName('qnumber')]
    public string $policyNumber;

    #[SerializedName('tradename')]
    public ?string $tradeName = null;

    #[SerializedName('address1')]
    public string $address1;

    #[SerializedName('address2')]
    public ?string $address2 = null;

    #[SerializedName('town')]
    public string $town;

    #[SerializedName('county')]
    public string $county;

    #[SerializedName('postcode')]
    public string $postcode;

    #[SerializedName('telephone')]
    public string $telephone;

    #[SerializedName('email')]
    public string $email;

    #[SerializedName('vehmake')]
    public string $vehicleMake;

    #[SerializedName('vehmodel')]
    public string $vehicleModel;

    #[SerializedName('vehregno')]
    public string $vehicleRegNo;

    #[SerializedName('vehtype')]
    public string $vehicleType;

    #[SerializedName('vehdatereg')]
    #[Context([DateTimeNormalizer::FORMAT_KEY => 'd/m/Y'])]
    public DateTimeImmutable $vehicleRegistrationDate;

    #[SerializedName('vehvin')]
    public string $vehicleVin;

    #[SerializedName('covertype')]
    public string $usageType;

    #[SerializedName('cover')]
    public string $coverageLevel;

    #[SerializedName('pcstart')]
    #[Context([DateTimeNormalizer::FORMAT_KEY => 'd/m/Y'])]
    public DateTimeImmutable $policyStartDate;

    #[SerializedName('pcend')]
    #[Context([DateTimeNormalizer::FORMAT_KEY => 'd/m/Y'])]
    public DateTimeImmutable $policyEndDate;

    #[SerializedName('scheme')]
    public string $schemeCode;

    #[SerializedName('vehyom')]
    public string $vehicleYearOfManufacture;

    #[SerializedName('vehweight')]
    public ?string $vehicleWeight = null;

    #[SerializedName('vehcolour')]
    public string $vehicleColour;

    #[SerializedName('totnet')]
    public string $totalNet;

    #[SerializedName('totgross')]
    public string $totalGross;

    #[SerializedName('totcomm')]
    public string $totalComm;

    #[SerializedName('iptgross')]
    public string $iptGross;

    #[SerializedName('vehadded')]
    #[Context([DateTimeNormalizer::FORMAT_KEY => 'd/m/Y'])]
    public DateTimeImmutable $vehicleAdded;

    #[SerializedName('comments')]
    public string $comments;
}
Enter fullscreen mode Exit fullscreen mode

Notice how the DTO serves multiple purposes:

  • Translates cryptic field names : qnumber -> policyNumber
  • Translates abbreviated field names : pcstart -> policyStartDate
  • Converts date like strings to DateTimeImmutable
  • readonly makes the request data immutable
  • Makes optional fields explicit with nullable types
  • Documents the data structure without needing external documentation
  • Provides autocomplete in an IDE

Now that we have our DTO, using it is surprisingly simple. Wiring a DTO to a controller in Symfony is trivial, thanks to #[MapRequestPayload]

<?php

declare(strict_types=1);

namespace App\Controller\Policy;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;

class WebsitePolicyPurchaseController
{
    #[Route(path: '/api/v1/policy/website-purchase', name: 'v1_policy_website_purchase', methods: [Request::METHOD_POST], format: 'json')]
    public function __invoke(#[MapRequestPayload] PolicyPurchasePayload $requestPayload): JsonResponse
    {
        return new JsonResponse();
    }
}
Enter fullscreen mode Exit fullscreen mode

But we're not done yet. This endpoint forms part of an API, which means it needs documentation. I rarely deal in absolutes. However this is one of them and it's very unlikely you'll convince me otherwise.

Thankfully this step is easy. Trivial in fact, if you use an LLM.

> can you add OA\Property descriptions to App\Modules\Policy\Application\Dto\PolicyPurchasePayload please? 

⏺ Read(src/Modules/Policy/Application/Dto/PolicyPurchasePayload.php)
  ⎿  Read 127 lines

⏺ Update(src/Modules/Policy/Application/Dto/PolicyPurchasePayload.php)
  ⎿  Updated src/Modules/Policy/Application/Dto/PolicyPurchasePayload.php with 1 addition
       13    use DateTimeImmutable;
       14 +  use OpenApi\Attributes as OA;
       15    use Symfony\Component\Serializer\Attribute\Context;
       16    use Symfony\Component\Serializer\Attribute\SerializedName;
       17    use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;

⏺ Update(src/Modules/Policy/Application/Dto/PolicyPurchasePayload.php)
  ⎿  Updated src/Modules/Policy/Application/Dto/PolicyPurchasePayload.php with 34 additions and 2 removals   

⏺ Done! I've added OA\Property descriptions to all 27 properties in the PolicyPurchasePayload.php file. Each description:

  - Clearly explains what the field represents
  - Includes examples where helpful (e.g., "Mr, Mrs, Ms, Dr" for title)
  - Notes the date format for datetime fields
  - Indicates optional fields where applicable
  - Uses insurance domain terminology (VRM, IPT, coverage, etc.)

  These descriptions will now appear in your OpenAPI/Swagger documentation at /api/doc, making the API much more
Enter fullscreen mode Exit fullscreen mode

In Symfony just install the NelmioApiDocBundle and add the attributes (or ask Claude to do it).

    #[OA\Property(description: "Trading name for business customers (optional)")]
    #[SerializedName('tradename')]
    private ?string $tradeName = null;

    #[OA\Property(description: "First line of address")]
    #[SerializedName('address1')]
    private string $address1;

    #[OA\Property(description: "Second line of address (optional)")]
    #[SerializedName('address2')]
    private ?string $address2 = null;
Enter fullscreen mode Exit fullscreen mode

Of course, you'll want to review what the LLM generated - but it's a solid starting point that takes minutes at most.

Spring Boot works in much the same way.

package com.example.policy.controller;

import com.example.policy.dto.PolicyPurchasePayload;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/policy")
public class WebsitePolicyPurchaseController {

    @PostMapping("/website-purchase")
    public ResponseEntity<?> handlePurchase(@RequestBody PolicyPurchasePayload requestPayload) {
        return ResponseEntity.ok().build();
    }
}
Enter fullscreen mode Exit fullscreen mode
package com.example.policy.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDate;

public record PolicyPurchasePayload(
    @Schema(description = "Customer title (e.g., Mr, Mrs, Ms, Dr)")
    @JsonProperty("title")
    String title,

    @Schema(description = "Customer first name")
    @JsonProperty("firstname")
    String firstName,

    @Schema(description = "Policy number assigned to the quote")
    @JsonProperty("qnumber")
    String policyNumber,

    @Schema(description = "Vehicle registration number")
    @JsonProperty("vehregno")
    String vehicleRegNo,

    @Schema(description = "Policy start date", example = "01/10/2025")
    @JsonProperty("pcstart")
    LocalDate policyStartDate

    // ... additional fields omitted for brevity
) {}
Enter fullscreen mode Exit fullscreen mode
  • @JsonProperty = #[SerializedName]
  • @Schema = #[OA\Property]
  • @RequestBody = #[MapRequestPayload]

I'm not tied to this particular way of doing things. I'm confident enough (90%) to write about it. Open minded enough (10%) to ask this honestly - why would you not create an API endpoint this way?

The argument I've seen most often is "boilerplate". Frankly I'd rather spend ten minutes writing the properties out than debugging why $data['pcStart'] throws an error, because it's actually $data['pcstart']

One thing I should mention for those who spotted it - my DTO has no constructor. This works because Symfony's serializer uses the ObjectNormalizer by default, this will leverage reflection to set property values directly. If performance is a concern, you have options: add a constructor, or use the GetSetMethodNormalizer with getters and setters. I haven't benchmarked the difference, but reflection does have some overhead. For most applications, this won't matter, but worth knowing the trade-off exists.

If we exclude validation (for now). The DTO we've built so far would be enough in some applications. However this isn't the only place I have to deal with email addresses, phone numbers and vehicle identifiers. The formatting of a phone number is especially important for my use case. The 'click to dial' integration with the phone system only works with E.164 formatted numbers.

That's where value objects come in handy.

Value Objects

Most languages give us types:

  • Scalar types such as string ,bool , int, float
  • Complex types such as array, Set, Map, DateTime

But here's the thing: 07123456789 and mr.bean@minicooper.co.uk are both strings. Yet one is a phone number and the other is an email address. They have different rules, different formats, different behaviors. Most languages recognised this problem for dates and gave us DateTime types. But what about everything else?

I like to think of value objects as custom types that narrow a scalar type down to what it actually is.

mixed -> string -> PhoneNumber

Let's take phone numbers as an example.

<?php

declare(strict_types=1);

namespace App\Domain\Common\ValueObject;

use InvalidArgumentException;
use libphonenumber\NumberParseException;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;

final class PhoneNumber extends AbstractStringValueObject
{
    private const string DEFAULT_REGION = 'GB';

    private function __construct(string $e164)
    {
        $this->value = $e164;
    }

    public static function fromString(string $value): self
    {
        $util = PhoneNumberUtil::getInstance();

        try {
            $proto = $util->parse(trim($value), self::DEFAULT_REGION);

            if (!$util->isValidNumber($proto)) {
                throw new InvalidArgumentException('Invalid phone number: ' . $value);
            }

            return new self($util->format($proto, PhoneNumberFormat::E164));
        } catch (NumberParseException) {
            throw new InvalidArgumentException('Invalid phone number: ' . $value);
        }
    }

    public function e164(): string
    {
        return $this->value;
    }

    public function international(): string
    {
        $util = PhoneNumberUtil::getInstance();
        $proto = $util->parse($this->value);

        return $util->format($proto, PhoneNumberFormat::INTERNATIONAL);
    }

    public function national(): string
    {
        $util = PhoneNumberUtil::getInstance();
        $proto = $util->parse($this->value);

        return $util->format($proto, PhoneNumberFormat::NATIONAL);
    }
}
Enter fullscreen mode Exit fullscreen mode

And then to use this value object

<?php

$phone = PhoneNumber::fromString('07951 123456');

$phone->international(); // +44 7951 123456
$phone->national(); // 07951 123456
$phone->e164(); // +447951123456

// This throws InvalidArgumentException
$phone = PhoneNumber::fromString('not a phone') 
Enter fullscreen mode Exit fullscreen mode

As I mentioned previously, the phone system requires a number in E.164 format. Most in the UK would expect to see a number in the national format. We also have validation - an invalid phone number will throw an exception.

Write a few test cases and you've extended the language with a custom 'type'

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Domain\Common\ValueObject;

use App\Domain\Common\ValueObject\PhoneNumber;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;

/**
 * Tests for PhoneNumber value object.
 * 
 * These tests document the expected behavior: accepting various formats,
 * always storing as E.164, and rejecting invalid numbers.
 */
final class PhoneNumberTest extends TestCase
{
    public function testCanCreateValidUkPhoneNumber(): void
    {
        $phone = PhoneNumber::fromString('07951 123456');

        // Should be stored in E.164 format
        $this->assertEquals('+447951123456', $phone->toString());
    }

    public function testCanFormatAsInternational(): void
    {
        $phone = PhoneNumber::fromString('07951123456');

        $this->assertEquals('+44 7951 123456', $phone->international());
    }

    public function testCanFormatAsNational(): void
    {
        $phone = PhoneNumber::fromString('07951123456');

        $this->assertEquals('07951 123456', $phone->national());
    }

    public function testCanParseInternationalFormat(): void
    {
        $phone = PhoneNumber::fromString('+44 7951 123456');

        $this->assertEquals('+447951123456', $phone->e164());
    }

    public function testRejectsInvalidPhoneNumber(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('Invalid phone number');

        PhoneNumber::fromString('not-a-phone');
    }

    public function testRejectsTooShortNumber(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('Invalid phone number');

        PhoneNumber::fromString('123');
    }
}
Enter fullscreen mode Exit fullscreen mode

Money is another good example for a value object. The legacy database I'm working with in these examples stores money as decimals. Those who have been doing this long enough will likely be saying to themselves - "rounding errors".

<?php

declare(strict_types=1);

namespace App\Domain\Common\ValueObject;

use InvalidArgumentException;
use Money\Currencies\ISOCurrencies;
use Money\Currency;
use Money\Formatter\DecimalMoneyFormatter;
use Money\Money as PhpMoney;
use Money\Parser\DecimalMoneyParser;

class Money extends AbstractStringValueObject
{
    private PhpMoney $money;

    private function __construct(PhpMoney $money)
    {
        $this->money = $money;

        $currencies = new ISOCurrencies();
        $formatter  = new DecimalMoneyFormatter($currencies);

        $this->value = $formatter->format($money);
    }

    public static function fromString(string $value): self
    {
        $currencies = new ISOCurrencies();
        $parser = new DecimalMoneyParser($currencies);

        $money = $parser->parse($value, new Currency('GBP'));

        return new self($money);
    }

    /**
     * Create from minor units (pence/cents) like 2808
     * 
     * @param int|numeric-string $amount
     * @param non-empty-string $currency
     */
    public static function fromMinor(int|string $amount, string $currency = 'GBP'): self
    {
        // PhpMoney expects a string for amount
        $money = new PhpMoney((string)$amount, new Currency($currency));

        return new self($money);
    }

    /** 
     * Minor units as string (e.g. "2808") 
     */
    public function amount(): string
    {
        return $this->money->getAmount();
    }

    /** 
     * ISO currency code (e.g. "GBP") 
     */
    public function currency(): string
    {
        return $this->money->getCurrency()->getCode();
    }

    /** 
     * Explicit decimal string (same as $this->value but named) 
     */
    public function toDecimalString(): string
    {
        return $this->value;
    }

    /** 
     * Access underlying PhpMoney if you need advanced ops 
     */
    public function unwrap(): PhpMoney
    {
        return $this->money;
    }

    /** 
     * Arithmetic returning the VO for convenience 
     */
    public function add(self $other): self
    {
        $this->assertSameCurrency($other);

        return new self($this->money->add($other->money));
    }

    /**
     * Arithmetic returning the VO for convenience
     */
    public function subtract(self $other): self
    {
        $this->assertSameCurrency($other);

        return new self($this->money->subtract($other->money));
    }

    private function assertSameCurrency(self $other): void
    {
        if (!$this->money->isSameCurrency($other->money)) {
            throw new InvalidArgumentException('Currency mismatch.');
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Because pragmatism - I've just wrapped PhpMoney and provided simple arithmetic methods. There's an unwrap method should you need to do anything more complex (currency conversion, percentage calculations, whatever PhpMoney supports that I haven't wrapped). No point in re-inventing the wheel.

<?php

// From legacy database decimals
$premium = Money::fromString('28.08');  // £28.08
$ipt = Money::fromString('3.01');       // £3.01 insurance premium tax

// Calculate total
$total = $premium->add($ipt);

// From payment gateway (Stripe/PayPal return pence/cents)
$payment = Money::fromMinor(3109);  // 3109 pence
$payment->toDecimalString();  // "31.09"

// Check if payment matches invoice
if ($payment->amount() === $total->amount()) {
    // Payment successful
}

// Calculate commission
$netPremium = Money::fromString('25.07');
$commission = Money::fromString('5.00');
$afterCommission = $netPremium->subtract($commission);

// Need something complex? Use unwrap()
$quarterly = $premium->unwrap()->multiply('4');
$discounted = $premium->unwrap()->multiply('0.9');  // 10% discount

// Store in database
$entity->setTotalGross($total->toDecimalString());  // Store as "31.09"
$entity->setTotalGrossMinor($total->amount());      // Store as "3109"
Enter fullscreen mode Exit fullscreen mode

As PhpMoney already has it's own unit tests, mine are fairly simple.

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Domain\Common\ValueObject;

use App\Domain\Common\ValueObject\Money;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;

/**
 * Tests for Money value object.
 *
 * Money handles currency amounts with precision and arithmetic operations.
 */
final class MoneyTest extends TestCase
{
    public function testCanCreateMoneyFromDecimalString(): void
    {
        $money = Money::fromString('123.45');

        $this->assertEquals('123.45', $money->toString());
        $this->assertEquals('GBP', $money->currency());
    }

    public function testCanCreateMoneyFromMinorUnits(): void
    {
        // 2808 pence = £28.08
        $money = Money::fromMinor(2808);

        $this->assertEquals('28.08', $money->toString());
        $this->assertEquals('2808', $money->amount());
    }

    public function testCanAddMoney(): void
    {
        $money1 = Money::fromString('100.00');
        $money2 = Money::fromString('50.50');

        $result = $money1->add($money2);

        $this->assertEquals('150.50', $result->toString());
    }

    public function testCanSubtractMoney(): void
    {
        $money1 = Money::fromString('100.00');
        $money2 = Money::fromString('30.25');

        $result = $money1->subtract($money2);

        $this->assertEquals('69.75', $result->toString());
    }

    public function testCannotAddMoneyWithDifferentCurrencies(): void
    {
        $gbp = Money::fromMinor(1000, 'GBP');
        $usd = Money::fromMinor(1000, 'USD');

        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('Currency mismatch');

        $gbp->add($usd);
    }
}
Enter fullscreen mode Exit fullscreen mode

There's loads of value object libraries available. But as you've seen - you don't need a lot of code to create some very useful little tools.

Now if we revisit our DTO we can firm up some of the types.

    #[OA\Property(description: "UK postcode")]
    #[SerializedName('postcode')]
    public Postcode $postcode;

    #[OA\Property(description: "Contact telephone number")]
    #[SerializedName('telephone')]
    public PhoneNumber $telephone;

    #[OA\Property(description: "Customer email address")]
    #[SerializedName('email')]
    public Email $email;

    #[OA\Property(description: "Total net premium amount")]
    #[SerializedName('totnet')]
    public Money $totalNet;

    #[OA\Property(description: "Total gross premium amount (including taxes)")]
    #[SerializedName('totgross')]
    public Money $totalGross;

    #[OA\Property(description: "Total commission amount")]
    #[SerializedName('totcomm')]
    public Money $totalComm;

    #[OA\Property(description: "Insurance Premium Tax (IPT) gross amount")]
    #[SerializedName('iptgross')]
    public Money $iptGross;
Enter fullscreen mode Exit fullscreen mode

There is just one extra step required for it all to function.

Our value objects are all constructed via the ::fromString() method. Symfony's serializer doesn't know this.

If I had £10 for every time I've had to look up this image, I'd probably be a very rich man. For some reason - it never sticks.

In both directions, data is always first converted to an array

When the request arrives at our application as JSON, it's converted to an array and then denormalized into our DTO.

If we wanted to convert our DTO into one of the listed formats (JSON, XML etc), it is normalized into an array first.

So that means:

  • on denormalization we need to call ValueObject::fromString()
  • on normalization we need to call ValueObject->toString()

You may have noticed my value objects all extend AbstractStringValueObject

<?php

declare(strict_types=1);

namespace App\Domain\Common\ValueObject;

abstract class AbstractStringValueObject implements StringValueObject
{
    protected string $value = '';

    final public function toString(): string
    {
        return $this->value;
    }

    final public function __toString(): string
    {
        return $this->value;
    }

    final public function equals(StringValueObject $other): bool
    {
        return $other::class === static::class && $this->value === $other->toString();
    }

    public function __serialize(): array
    {
        return ['value' => $this->value];
    }

    /** 
     * @param array{value?:string} $data 
     */
    public function __unserialize(array $data): void
    {
        $this->value = (string) ($data['value'] ?? $this->value ?? '');
    }
}
Enter fullscreen mode Exit fullscreen mode

which implements StringValueObject

<?php

declare(strict_types=1);

namespace App\Domain\Common\ValueObject;

interface StringValueObject
{
    public static function fromString(string $value): self;

    public function toString(): string;

    public function equals(self $other): bool;

    public function __toString(): string;
}
Enter fullscreen mode Exit fullscreen mode

So my custom normalizer/denormalizer needs to support all classes that implement StringValueObject

<?php

declare(strict_types=1);

namespace App\Serializer;

use App\Domain\Common\ValueObject\StringValueObject;
use ArrayObject;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

final class ValueObjectNormalizer implements NormalizerInterface, DenormalizerInterface
{
    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
    {
        return $data instanceof StringValueObject;
    }

    public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|ArrayObject|null
    {
        return $data->toString();
    }

    public function supportsDenormalization(mixed $data, string $type, $format = null, array $context = []): bool
    {
        return is_string($data)
            && is_subclass_of($type, StringValueObject::class);
    }

    public function denormalize($data, $type, $format = null, array $context = []): object
    {
        /** @var class-string<StringValueObject> $type */
        return $type::fromString($data);
    }

    public function getSupportedTypes(?string $format): array
    {
        // We support “all classes implementing the interface” at runtime.
        // Ask Symfony to always call supportsDenormalization().
        return [
            StringValueObject::class => true,
            '*' => false,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Symfony will automatically register this class and append it to the normalizer chain.

root@6b279478af45:/app# bin/console debug:container --tag serializer.normalizer

Symfony Container Services Tagged with "serializer.normalizer" Tag
==================================================================

 ---------------------------------------------------- ---------- ---------- ---------------------------------------------------------------------------
  Service ID                                           built_in   priority   Class name
 ---------------------------------------------------- ---------- ---------- ---------------------------------------------------------------------------
  serializer.denormalizer.unwrapping                   1          1000       Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer
  App\Serializer\ValueObjectNormalizer                                       App\Serializer\ValueObjectNormalizer
  serializer.normalizer.problem                        1          -890       Symfony\Component\Serializer\Normalizer\ProblemNormalizer
  serializer.normalizer.uid                            1          -890       Symfony\Component\Serializer\Normalizer\UidNormalizer
  serializer.normalizer.datetime                       1          -910       Symfony\Component\Serializer\Normalizer\DateTimeNormalizer
  serializer.normalizer.constraint_violation_list      1          -915       Symfony\Component\Serializer\Normalizer\ConstraintViolationListNormalizer
  serializer.normalizer.mime_message                   1          -915       Symfony\Component\Serializer\Normalizer\MimeMessageNormalizer
  serializer.normalizer.datetimezone                   1          -915       Symfony\Component\Serializer\Normalizer\DateTimeZoneNormalizer
  serializer.normalizer.dateinterval                   1          -915       Symfony\Component\Serializer\Normalizer\DateIntervalNormalizer
  serializer.normalizer.form_error                     1          -915       Symfony\Component\Serializer\Normalizer\FormErrorNormalizer
  serializer.normalizer.backed_enum                    1          -915       Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer
  serializer.normalizer.number                         1          -915       Symfony\Component\Serializer\Normalizer\NumberNormalizer
  serializer.normalizer.data_uri                       1          -920       Symfony\Component\Serializer\Normalizer\DataUriNormalizer
  serializer.normalizer.translatable                   1          -920       Symfony\Component\Serializer\Normalizer\TranslatableNormalizer
  serializer.normalizer.json_serializable              1          -950       Symfony\Component\Serializer\Normalizer\JsonSerializableNormalizer
  serializer.denormalizer.array                        1          -990       Symfony\Component\Serializer\Normalizer\ArrayDenormalizer
  serializer.normalizer.object                         1          -1000      Symfony\Component\Serializer\Normalizer\ObjectNormalizer
 ---------------------------------------------------- ---------- ---------- ---------------------------------------------------------------------------
Enter fullscreen mode Exit fullscreen mode

In the controller you'll now have a fully populated DTO including the value objects.

That's It

DTOs for boundaries. Value objects for type safety. A custom normalizer to make them play nicely together.

Your legacy system still sends qnumber and pcstart, but your code works with policyNumber and policyStartDate. And phone numbers in the wrong format? They no longer break the phone system.

These patterns aren't just for legacy systems, they're useful everywhere.

Next up: Temporal - or how to make sure PagerDuty doesn't wake you up at 3am because AWS broke DynamoDB again.

Top comments (5)

Collapse
 
xwero profile image
david duymelinck • Edited

I agree with not thinking in absolutes. The patterns and architectures that exists are just concepts that guide us to create good code. They should not be followed by the letter.

I also add attributes to DTO's. What I'm not doing is adding types to the properties. This already adds a form of validation, it is better to keep the validation centralized. Or cut it up per layer, to not mix concerns when validating. And consolidate the layered validation to allow the user to make the changes that will save the data in the application.

I think value objects are sometimes used with a specific storage solution in mind, for example the PhoneNumber and Money in the post. For PhoneNumber a format, landcode and phone number, is created as a string. The best way to store the data in a relational database is a table with landcodes and a table with phone numbers and the id of the landcode. For MongoDB it is an object with the landcode and the phone number.
The value object should be able to be converted to both options, and that is why a single string is the worst solution.

The StringValueObject is a bit much for my liking. Why you need toString and __toString methods?
If you want to enforce the __toString method just add the Stringable PHP interface. This allows you to use explicit typing to get the instance value, (string) $phoneNumber.
While patterns are well known, I think it is better to use the functionality the language offers out of the box.

I think validation is more important than having value objects. If you want the least error prone code both are needed. But start with making the validation as bulletproof as possible.

Collapse
 
clegginabox profile image
Paul Clegg

Thanks for taking the time to write such a thoughtful comment, I really appreciate it.

I very much agree with the core of what you’re saying: patterns are guidelines and tools, not rules.

On validation: I had originally planned to cover JSON Schema in this article. In my case the external site validates the payload, which then flows through API Gateway → SNS → SQS → Lambda → API endpoint. Because of that pipeline I don’t use Symfony validation constraints on the DTOs – they’re validated against the JSON Schema instead. The article was already getting long, so I decided not to go deep into that topic here.

For the value objects, I deliberately didn’t think about the persistence layer too much. I’m tied to a single string column in the legacy database anyway, but the VO could easily be adjusted to expose the phone number in whatever shape a given storage layer needs.

As for the StringValueObject: I needed a common target for the serializer. My VOs are expected to implement both fromString and toString / __toString(). In the real code it’s slightly more complex than in the article because they also implement the Null Object pattern (en.wikipedia.org/wiki/Null_object_...).

I’ve been toying with the idea of doing a “Part 1.5” that digs into validation, serialisation and some of these details before jumping into Temporal. Your comment is a good nudge in that direction!

Collapse
 
xwero profile image
david duymelinck • Edited

I’m tied to a single string column in the legacy database anyway, but the VO could easily be adjusted to expose the phone number in whatever shape a given storage layer needs.

I think keeping the land code an phone number separate in the value object offers the best compatibility. It is not the value object that should conform to the database format. That is the task of the of the database layer.
User::insert(['phone_number' => $phoneNumber->landCode .'-'.$phoneNumber->phoneNumber]);

I needed a common target for the serializer. My VOs are expected to implement both fromString and toString / __toString().

Why? If you add value objects to a project, I assume you are also changing the serializer?
So why not extend the serializer that it can process different objects?

I think they are both cases of mixing concerns.

Thread Thread
 
clegginabox profile image
Paul Clegg • Edited

I think we are aiming for the same goal (flexibility in storage) but approaching it from different angles.

You are absolutely right that the storage layer might need the data split into land_code and number. However, I would argue that making the Value Object store it that way couples the Domain to the Database. You mentioned the best compatibility, what did you mean by that?

I’m using (libphonenumber-for-php) which already lets you split a single canonical number into the structured parts you mentioned, without changing how the VO stores its state:

    public function toParts(): array
    {
        $util  = \libphonenumber\PhoneNumberUtil::getInstance();
        $proto = $util->parse($this->e164, 'GB');

        return [
            'country_code'    => $proto->getCountryCode(),       // 44
            'national_number' => (string) $proto->getNationalNumber(), // 7700123456
        ];
    }
Enter fullscreen mode Exit fullscreen mode
// Database wants separate fields
[$code, $number] = $phone->toParts();

// API wants E.164
$payload['phone'] = $phone->toE164();

// UI wants a prettier national format
echo $phone->toNationalFormat();
Enter fullscreen mode Exit fullscreen mode

The last section of the article covers a custom normaliser/denormaliser that handles my VO's. Is there something in there I could clarify?

Hope that clears up the intent! And genuinely thanks again for engaging thoughtfully. This kind of discussion is exactly why I wrote the article.

Thread Thread
 
xwero profile image
david duymelinck

I would argue that making the Value Object store it that way couples the Domain to the Database. You mentioned the best compatibility, what did you mean by that?

Splitting the landcode and the phone number is the same as the in the Money object where there is the amount and the currency. It has no connection with the database, because it is possible you need extra logic to store the phone number. That was what I was trying to demonstrate with the code example.

a custom normaliser/denormaliser that handles my VO's

A (de)normalizer should be able to handle multiple types, instead of a single type.

I think the goal is to handle data in the different layers of an application. Different layers can have different shapes for the same data. That is why there are DTO's, (de)normalizers and mappers.
When you want to use a same data shape for different layers, it is going to be harder to keep layer specific things separate.