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:
- Rector
- Domain-Driven Design (DDD)
- JSON Schema
- Strangler Fig
- Temporal
- Docker (including the joy of compiling PHP 5.4 from source (in 2025) so it can run on an M-series Mac)
- Terraform
- Various AWS services
This first article focuses on DTOs and Value Objects. Three 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"
}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;
}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 readonlymakes 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();
}
}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
understandable for consumers.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;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();
}
}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
) {}@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);
}
}
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') 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');
}
}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.');
}
}
}
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"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);
}
}
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;
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 ?? '');
}
}
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;
}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,
];
}
}
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
---------------------------------------------------- ---------- ---------- ---------------------------------------------------------------------------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.
Comments ()