Clean Model Always Wins: A Strangler Pattern Data Sync Case Study
This article is a real-life case study of modernizing a legacy PHP application with the Strangler Pattern - migrating a product catalogue from Zend Framework to Symfony, syncing data between the two systems, and the lessons learned from years in production.
TL;DR
- We strangled a product catalogue out of a legacy Zend Framework app into Symfony. Legacy stayed the only writer, the new app the only reader.
- The entire data sync: database triggers filling a queue table of changed IDs, and a cron job pulling batches every few minutes. No Kafka, no Debezium.
- Reliability came from at-least-once delivery plus an idempotent consumer - implemented with a
TIMESTAMPcolumn and one guardedDELETE. - The real lesson: focus on a clear domain and clean boundaries, not fancy tools. A clean model with an anti-corruption layer did far more for this project than Kafka or Debezium ever could.
When implementing a Strangler Pattern approach, one of the key things to master is to keep the new data model clean. More often than not, the model is transferred, or the new model is heavily influenced by the legacy one. As a result, the new system has a newer framework, slightly better codebase, but the deep modelling issues remain, and they make development slow and hard.
A few weeks ago I published a practical guide to the Strangler Pattern. In it I made a claim that deserves proof: when you split the database during a migration, the synchronization between the old and the new store does not have to be sophisticated. More often than you would expect, a delay is perfectly acceptable, and that makes your life much easier.
This is a case study from one of our long-running projects: a marketplace platform where a legacy Zend Framework application and a new Symfony application ran side by side for years.
The background
The platform lets shop owners design custom apparel and sell it in their own storefronts. The legacy side is a classic Zend Framework 1 application - thousands of files, models mixed with raw SQL, an admin panel, background jobs, the works. The new side was planned as a Symfony application with a properly modeled domain: bounded contexts for products, shops and currencies, repositories, value objects.
Finding the right place to strangle
For a migration to succeed, it needs to deliver value to the business. No business owner will be happy to pay for months or years of work without a positive return on investment (ROI). A good place to start needs to combine a positive ROI, low risk, and being rather easy to work on if you are new to the project (and we were).
After some research, many discussions with the business owners, and a code review, we have chosen to start with a product catalogue page. Some reasons why this was a good candidate:
- The catalogue, especially its search was super slow, even timing out in some cases.
- This is a key step in the buyer journey.
- A catalogue model was quite easy to design...
- ... and the sync was easy to implement - as you will see later.
The important constraint: the legacy application remains the system of record for products. Shop owners still create and edit products through the legacy admin. The new application only renders the catalog and the product pages (as a second step), from its own database, with its own clean model. That single decision - one writer, one reader - is what allows the sync to stay simple. Data flows in exactly one direction, and there is no risk of corrupted data. Legacy is always the source of truth (at least at this stage).
The second decision that keeps things simple: eventual consistency is fine here. If a shop owner renames a product and the public catalog shows the old name for three more minutes, nobody notices and nothing breaks. No money is moving. The moment you accept that, you can throw away most of the complicated options.
The initial setup
So we introduced a new Symfony application that ran next to the old one. The HTTP(S) routing was very simple, and we kicked it off with just a couple of Apache config rules (Apache was already running):
# New app takes the product pages...
ProxyPass /p http://new/p
# ...and the catalogue
ProxyPass /c http://new/c
# Everything else stays on legacy
ProxyPass / http://legacy/With that in place, the new application needed the legacy product data - and after considering different approaches, the whole synchronization mechanism came down to three pieces:
- a database trigger that records which products changed,
- a queue table holding the IDs of products waiting to be synced,
- a cron job on the new side that pulls the changes every few minutes.
No Kafka, no Debezium, no message broker. And it ran in production, reliably, for years.
I will walk through the whole pipeline end to end, including the anti-corruption layer that kept the legacy schema out of the new domain model. And because we wrote this code quite a while ago, we know how it aged: it ran for years with very little maintenance.
Step 1: Tracking changes with database triggers
The first problem: how do you know a product changed? The textbook answer is to publish an event from the application whenever a product is saved. The textbook answer assumes your application has one place where products get saved.
Ours had many. Products were written by the shop owner panel, by the admin panel, by CSV importers, by background jobs, and by a couple of raw SQL statements scattered where you would least expect them. Finding and instrumenting every write path in a legacy codebase is exactly the kind of archaeology the Strangler Pattern is supposed to spare you. It is just risky, and the chances you will miss one are too high.
So we went one level below the application, where all those write paths converge anyway: the database. A queue table and a set of triggers:
CREATE TABLE `shop_item_updated` (
`shop_item_id` bigint(20) unsigned NOT NULL,
`updated_at` TIMESTAMP NOT NULL,
PRIMARY KEY (`shop_item_id`)
);
CREATE TRIGGER after_shop_item_updates
AFTER UPDATE ON shop_item
FOR EACH ROW
BEGIN
INSERT INTO shop_item_updated (shop_item_id, updated_at)
VALUES (OLD.id, NOW())
ON DUPLICATE KEY UPDATE updated_at = NOW();
END;
CREATE TRIGGER after_shop_item_updates_insert
AFTER INSERT ON shop_item
FOR EACH ROW
BEGIN
INSERT INTO shop_item_updated (shop_item_id, updated_at)
VALUES (NEW.id, NOW());
END;Two design details here matter more than they look.
The queue stores dirty flags, not events. There is no payload, no "what changed", no event type. Just "product 4711 changed at 14:32". That has a lovely consequence: the primary key on shop_item_id plus ON DUPLICATE KEY UPDATE means the queue naturally coalesces. A product edited fifteen times between two sync runs occupies one row and gets synced once. The queue physically cannot grow beyond the number of products. You get deduplication for free, from a primary key.
If you know the theory: this is a poor man's transactional outbox crossed with change data capture. The trigger runs in the same transaction as the write, so a change and its queue entry are atomic - you cannot lose a change the way you can with "save, then publish to a broker". Tools like Debezium give you the same guarantee by tailing the binlog, with ordering and payloads on top. We did not need ordering or payloads, so we did not pay for them. And the price of such tools is easy to underestimate: adding Debezium is a day of work, but from that day on you are running and monitoring Kafka Connect, upgrading it, and fixing it when a connector silently stops. With the trigger approach, there was simply nothing extra to operate.
A product page is not built from one table, though. It also depends on related tables like translations, so those got triggers too - each one resolving back to the affected product IDs:
CREATE TRIGGER after_shop_item_language_updates
AFTER UPDATE ON shop_item_language
FOR EACH ROW
BEGIN
INSERT INTO shop_item_updated (shop_item_id, updated_at)
VALUES (OLD.shop_item_id, NOW())
ON DUPLICATE KEY UPDATE updated_at = NOW();
END;And the same pattern was repeated for the other synced entities like shops - one queue table per aggregate.
The mechanism evolved through production bugs, which I think is worth admitting. Products on this platform have child variants, and the first version of the trigger only enqueued the row that changed. Result: edit a parent product, and its variants quietly kept serving stale data. The fix was a follow-up migration that widened the trigger:
CREATE TRIGGER `after_shop_item_updates`
AFTER UPDATE ON `shop_item`
FOR EACH ROW
BEGIN
INSERT INTO shop_item_updated
SELECT id, NOW() FROM shop_item
WHERE id = OLD.id OR parent_id = OLD.id
ON DUPLICATE KEY UPDATE updated_at = NOW();
ENDThis is the trade you make with triggers: the logic of "what does this change affect" lives in SQL, in the database, where nobody is looking. Trigger logic does not show up in the PHP codebase, code search does not find it, and new developers do not know it exists. If you go this route, treat triggers as first-class code: keep them in versioned migration files, mention them in the developer docs, and keep them dumb - flag an ID, nothing more. Thankfully, we caught the issue very fast, and the fix was easy.
Step 2: Exposing the changes - the legacy half of the ACL
The new application could have just connected to the legacy database and read the queue table directly. We deliberately did not do that. Instead, the legacy application exposes an internal HTTP API, and this is where the anti-corruption layer starts - on the legacy side.
The endpoint joins the queue table against the product data and returns a batch, oldest changes first:
// Legacy (Zend) - the internal sync endpoint, trimmed
public function listproductsAction()
{
$this->checkIp();
$shopItemModel = new Default_Model_ShopItem();
$shopItems = $shopItemModel->getForSyncWithJoins($this->getParam('limit'));
$productMapper = new ProductMapper($this->view);
$mapped = [];
// $images, $children and $categoryMapper are prepared
// for the whole batch - trimmed for brevity
foreach ($shopItems as $row) {
$shopItem = ShopItemFactory::create($row, $images);
$product = $productMapper->mapToProduct($row, $shopItem, $children);
$product['category'] = $categoryMapper->mapToTypeAndAssortment(
$shopItem->getPatternCategoryId()
);
$mapped[] = $product;
}
$this->getResponse()
->setBody(json_encode($mapped))
->setHttpResponseCode(200);
}The interesting part is what ProductMapper does. It does not dump the legacy row. It builds a deliberate contract - clean names, resolved relations, computed flags:
// Legacy - mapping internal mess into a published contract
public function mapToProduct(array $row, Default_Entity_ShopItem $shopItem, ...)
{
return [
'id' => $shopItem->getId(),
'name' => $shopItem->getName(),
'price' => $shopItem->getPrice(),
'description' => html_entity_decode($shopItem->getDescription()),
'updated_at' => $row['updated_at'],
'shop' => [
'subdomain' => $shopItem->getSubdomain(),
'externalUri' => $shopItem->getExternalDomain(),
'id' => $shopItem->getShopId(),
],
// three legacy flags collapse into one meaningful boolean
'deleted' => $row['is_deleted']
|| $row['parent_is_deleted']
|| $row['pattern_is_deleted'],
'enabled' => !$shopItem->isDisabled() && !$row['pattern_is_hidden'],
// ...
];
}Look at that deleted line. In the legacy schema, "is this product gone" is a question you can only answer by checking three flags across three tables - the product, its parent, and its pattern. The mapper answers the question once, at the boundary, and the new system never learns the question existed. That is anti-corruption in one line of code.
In DDD terms this endpoint is an open host service speaking a published language: the legacy context translates its internal chaos into an agreed contract before anything crosses the boundary. It also means the legacy team can refactor their tables without breaking the new app, as long as the JSON stays the same. We got to use that freedom more than once.
One comment worth mentioning here: the three different delete flags might make sense in some cases, but not in ours. We were rebuilding a product catalogue, and for a catalogue it does not matter why a product got deleted. We only care whether it is deleted. If you know you will need those distinctions later, you might want to expose the flags separately and implement that part of the ACL in the new system.
Step 3: Pulling the changes - the cron job
On the Symfony side, a single console command runs from cron:
# ansible crontab role
- name: sync
minute: "*/3"
job: "php bin/console -eprod app:sync -v >> cron.log 2>&1"Every three minutes. That number was not scientific - it was "fast enough that no shop owner ever complained, slow enough that the batches stay small". We never had to change it.
The command itself is mostly plumbing: a lock so runs cannot overlap, and a list of sync providers:
class SyncCommand extends Command
{
use LockableTrait;
const TYPES = ['shop', 'product' /*, ... and more */];
protected function execute(InputInterface $input, OutputInterface $output)
{
if (!$this->lock()) {
$output->writeln('The command is already running in another process.');
return;
}
try {
$this->syncManager->syncAll();
} catch (\Exception $e) {
$this->release();
throw $e;
}
// ...
$this->release();
}
}The LockableTrait is doing quiet but essential work here. Batches sometimes take longer than three minutes - a big CSV import on the legacy side can enqueue thousands of products at once. Without the lock, cron would happily start a second run on top of the first, and you would sync the same products twice, concurrently, into the same tables. One trait, one line, whole class of bugs gone.
Synchronizers register through a Symfony tag with priorities, because order matters - you cannot sync a product into a shop that does not exist yet:
App\Infrastructure\Synchronizer\ShopSynchronizer:
tags: [{ name: synchronizer, priority: 90 }]
App\Infrastructure\Synchronizer\ProductSynchronizer:
tags: [{ name: synchronizer, priority: 80 }]Shops before products. Dependency ordering as configuration - simple and visible.
The product synchronizer is where the batch is processed. Trimmed to its essence:
class ProductSynchronizer implements Synchronizer
{
const LIMIT = 200;
public function sync()
{
$timestamp = (new \DateTime())->getTimestamp();
$productsData = $this->productApi->findAll(self::LIMIT);
$synchronized = [];
foreach ($productsData as $product) {
try {
$this->synchronizeProduct($product);
} catch (\Exception $e) {
$this->logError(self::LOG_MESSAGE, ['error' => $e->getMessage()]);
continue; // one broken product must not stop the batch
}
$synchronized[] = $this->productTranslator->getId($product);
}
$this->em->flush();
$this->productApi->markAsSynchronized($synchronized, $timestamp);
}
}Three things to notice:
- A batch limit. 200 products per run. A giant import does not blow up memory or block the cron slot for an hour; it just drains over several runs, oldest first.
- Per-item error isolation. One product with broken data logs the error and is skipped. The other 199 sync fine. Without this, a single malformed row would wedge the entire pipeline - and in a legacy system, malformed rows are a certainty, not a risk.
- Only successes are confirmed. The failed product's ID never makes it into
$synchronized, so its queue row survives and it will be retried on the next run.
Step 4: Confirming - and why the timestamp matters
The confirmation call is the most subtle part of the whole design. The new side reports back which products it managed to process, along with a timestamp:
public function markAsSynchronized(array $ids, int $timestamp)
{
$this->client->request('POST', 'synchronizedProducts', [
'json' => ['ids' => $ids, 'timestamp' => $timestamp],
]);
}And the legacy side deletes the queue rows - but only if they have not been touched since:
DELETE FROM shop_item_updated
WHERE shop_item_id IN (:ids)
AND UNIX_TIMESTAMP(updated_at) <= :timestampThat updated_at <= timestamp condition is what makes the mechanism safe. Consider the race: the sync fetches product 4711, and while the batch is being processed, a shop owner edits that same product. The trigger bumps updated_at in the queue. When the confirmation arrives, the timestamp check sees a newer updated_at and refuses to delete the row - so the product is synced again next run, picking up the fresh edit. A naive DELETE WHERE id IN (...) would have silently lost that update.
This gives you at-least-once delivery: a product may be synced twice, but never zero times. At-least-once only works if processing is idempotent, and here it is - the new side upserts by the legacy product number through mapping tables, so syncing the same product twice just overwrites identical data.
The pattern at work here has a name: at-least-once delivery plus idempotent consumer. It is the boring, battle-tested foundation under most reliable messaging systems, and you can implement it with a TIMESTAMP column and a DELETE ... WHERE.
Step 5: The new side's ACL - translator, event, listener
The data that arrives is clean-ish JSON, but it is still the legacy contract - legacy IDs, legacy category names, legacy quirks like 0000-00-00 00:00:00 dates. None of that is allowed anywhere near the new domain model. The new side runs it through three stages.
Stage one: the translator. It absorbs the quirks and maps legacy identifiers onto the new world:
class LegacyProductTranslator implements ProductTranslator
{
public function createFromArray(array $data): array
{
// legacy pattern_id -> SKU of the base item in the NEW catalog
$baseItemMap = $this->patternMapRepository
->findOneByPatternId($data['pattern_id']);
// has this legacy product been synced before?
$productMap = $this->productVersionMapRepository
->findOneByLegacyId($data['id']);
// a classic MySQL legacy quirk, absorbed here and nowhere else
if ('0000-00-00 00:00:00' == $data['created_at']) {
$data['created_at'] = '1970-01-01 00:00:00';
}
return [
'number' => $data['id'],
'base_item_sku' => $baseItemMap ? $baseItemMap->getBaseItemSku() : null,
'current_product_sku' => $productMap ? $productMap->getProductSku() : null,
'name' => $data['name'],
'created_at' => new \DateTimeImmutable($data['created_at']),
'enabled' => filter_var($data['enabled'], FILTER_VALIDATE_BOOLEAN),
'deleted' => filter_var($data['deleted'], FILTER_VALIDATE_BOOLEAN),
'prices' => [
$shop->getDefaultCurrency()->getIsoCode() => $data['price'],
],
// ...
];
}
}Those two repository lookups are the heart of the ACL. The old and the new system do not share identifiers - the legacy world thinks in numeric pattern_ids, the new catalog thinks in SKUs. Dedicated mapping tables (BaseItemVersionToPatternMap, ProductVersionToLegacyMap) are the only place where both ID schemes appear together. The domain model itself carries no legacyId column. When legacy dies, you drop the mapping tables and the domain does not notice.
Stage two: a validated event. The translated payload becomes an explicit event with the contract enforced in the constructor:
class ProductDataChangedEvent
{
public static function create(array $payload): self
{
$self = new self();
$self->validate($payload); // asserts required fields exist
$self->payload = $payload;
return $self;
}
public function getName(): string
{
return $this->payload['name'];
}
// ... typed getters for every field
}Stage three: a domain listener that speaks only the new language. This is the first place in the whole pipeline where domain objects are built, and notice what it imports - repositories, value objects, factories from the new bounded context. Not one legacy concept:
class UpdateProductEventListener
{
public function __invoke(ProductDataChangedEvent $event)
{
$baseItem = $this->baseItemRepository->findOneBySku($event->getBaseItemSku());
if (!$baseItem) {
return; // base item not in the new catalog -> not our concern yet
}
$shop = $this->shopRepository->findOneByIdentifier($event->getShopIdentifier());
$sku = $baseItem->getSku() . '_' . $this->skuGenerator->getSKU() . '_' . $event->getNumber();
$commission = Commission::PERCENTAGE === $event->getCommissionType()
? Commission::createPercentage($event->getCommissionValue() / 100)
: Commission::createFixed([new Price($shop->getDefaultCurrency(), $event->getCommissionValue())]);
$product = new Product(
$sku, $baseItem, $event->getName(), null, $shop,
$event->getDescription(), $commission, $event->getCreatedAt()
);
$event->isDeleted() ? $product->delete() : $product->restore();
$this->productRepository->save($product, true);
}
}In my practical guide I gave a simple test for a real ACL: grep your new codebase for the word legacy - every hit outside the ACL is a leak. Running that grep on this project, the hits cluster in Infrastructure/ - LegacyProductApi, LegacyProductTranslator, the mapping entities. The Domain/Model directory comes back clean. Held for years, that line is the difference between a migration and a reshuffle. The word itself is not the point - extend the test to any legacy concept you deliberately dropped from the new model, and you will not find those either.
Was it perfect? No. The translator returns an array instead of a typed object - there is literally a @todo this should maybe return a Product instance? on the interface, and it stayed a todo for years. If I were building it today, that boundary would be a typed DTO validated field by field. But the placement of the boundary was right, and placement is what you cannot fix later.
And that is it - the full sync, end to end. Based on the domain events and models, we were able to build a new, fast and efficient product catalogue, and later easily extend it to product pages with pricing info. Unfortunately, the migration was stopped soon after, for business rather than technical reasons - so this time we never got to click "delete" on the legacy part the way we did in other projects. More on that in the closing thoughts.
Why simple won
Step back and count the moving parts: two queue tables, a dozen triggers, one internal HTTP API, one console command, one cron entry. That is the entire synchronization infrastructure for strangling the product catalog out of a monolith. No broker to operate, no CDC connector to babysit, no schema registry. When sync misbehaved, debugging looked like SELECT * FROM shop_item_updated ORDER BY updated_at LIMIT 20 and reading a cron log.
The simplicity was not luck. It was bought by three constraints we chose early and defended:
- One-directional flow. Legacy writes, new reads. The moment data flows both ways, you are in conflict-resolution territory and none of the code above survives.
- Eventual consistency, explicitly accepted. Three minutes of staleness was a business decision, made once, with the product owner in the room. Every relaxation like this removes a whole layer of machinery.
- At-least-once plus idempotency, instead of trying for exactly-once. Duplicates are free when the consumer upserts; lost updates are never free. Design the failure direction, then stop worrying.
And around the simple pipe, the discipline that actually mattered: a mapper publishing a clean contract on the way out of legacy, and a translator absorbing quirks on the way into the new system, so that after years of syncing, the new domain model still did not know what a pattern_is_hidden flag was.
The sync outlived every estimate we made for it, which is true of most "temporary" migration infrastructure - in the guide I called this the "short-lived forever" state, and this project is where I learned it. Build the temporary thing simple enough that running it for years does not hurt, and clean enough that deleting it is a DROP TRIGGER and a git rm.
FAQ
Why not use Debezium or another CDC tool instead of triggers?
For this project, operational cost. Debezium means running Kafka Connect (or at least a Debezium server), monitoring a binlog reader, and handling connector failures - permanent infrastructure for a temporary migration. The trigger approach needed nothing that was not already there. CDC earns its keep when you need ordered event streams with payloads, multiple consumers, or near-real-time latency. For one consumer that tolerates minutes of delay, a queue table is hard to beat. And it served the traffic we had pretty well.
Does polling every few minutes scale?
Better than intuition suggests, because the queue coalesces. The batch size is bounded by "products changed since the last run", not by write volume - a product saved fifty times is still one row. Our batches rarely approached the 200 limit outside of bulk imports, and those simply drained over a few consecutive runs. If your change rate outgrows the interval permanently, that is the signal to move to a streaming approach - but measure first.
Why pull from the new side instead of legacy pushing changes?
Pull keeps the legacy side passive and nearly untouched - the strangler ideal. A push design means the legacy app must know the new system's address, handle its downtime, implement retries and backoff. With pull, all the intelligence lives in the new codebase, where you actually want to write code, and legacy's only job is to answer two HTTP endpoints. If the new side is down, changes accumulate safely in the queue table until it comes back.
Is a database trigger not an antipattern itself?
Triggers holding business logic are a well-earned antipattern - invisible, hard to test, painful to debug. Triggers as a change-tracking mechanism are a different animal: a single dumb statement, no decisions, flagging an ID. That is a pragmatic form of change data capture, and it solved the one problem application-level events cannot solve in a legacy system - catching the write paths you do not know about. Keep them dumb, keep them in versioned migrations, document them where developers will look.
How do you keep the two systems' IDs from contaminating each other?
Dedicated mapping tables, owned by the new system's infrastructure layer. Legacy IDs never appear on domain entities - only in *ToLegacyMap tables that pair a legacy identifier with a new SKU. The domain model references only its own identifiers. When the legacy system is finally decommissioned, the mapping tables are dropped and the domain schema does not change at all - which is a good test of whether your ACL is real.
Closing thoughts
Does this always work? No, it does not, and it should not be your default approach. Triggers, queue tables and cron jobs are one possible answer, chosen for one specific situation: a single writer, a single reader, and a business that could accept a few minutes of delay. What I wanted to show is that good modelling and an open mind are the key to a simple, easy to maintain solution. When the boundaries are right, the machinery between them can stay boring.
There is one more reason this project stuck with me. As I mentioned, the migration was eventually stopped - a business decision, nothing to do with the technology. And nothing broke. We did not back out of a half-finished rewrite, nobody scrambled to patch a system stuck between two worlds. We simply stopped, with every strangled piece still running in production and the legacy app still handling the rest. In the practical guide I wrote that if you have to pause a strangler migration, you still have a fully working product. This is the project where I saw that safety net catch a real fall.



