Legacy is everywhere. Every company has legacy code, if they tell you otherwise they’re bullshitting you. It’s an absolute fact.
I know what you’re thinking, Stu, come on now, companies don’t lie?
I mean, they talk about “competitive wages” and “free pizza” like they’re giving you a sweet deal and you should be grateful. But let’s be real: one slice of lukewarm pepperoni and a salary that’s 20% below market doesn’t make you great company to work for, bro.
It makes you a tech startup with a ping pong table, statutory sick pay, staff with their own choice of laptop (mac or windows), and the minimum legally allowed holiday entitlement like it’s some kind of balls-to-the-wall epic perk that you should be honoured to receive.
But yeah, let’s talk about legacy systems.
Some spaghetti…
Let’s say we have a legacy monolithic app that handles:
Orders
Payments
Users
Inventory
All of these are in a spaghetti mess like this:
No wait, hang on…
#GetSpaghettiCodeOutOfYourMotherFuckingMouth
It’s more like this…
class OrderController
{
public function placeOrder(array $postData): void
{
$userId = $postData['user_id'];
$itemId = $postData['item_id'];
$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', '');
// Get user
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$userId]);
$user = $stmt->fetch();
// Get item
$stmt = $pdo->prepare("SELECT * FROM items WHERE id = ?");
$stmt->execute([$itemId]);
$item = $stmt->fetch();
if ($item['stock'] <= 0) {
die('Out of stock!');
}
// Charge card (pretend)
$this->chargeCard($user['card'], $item['price']);
// Decrement stock
$stmt = $pdo->prepare("UPDATE items SET stock = stock - 1 WHERE id = ?");
$stmt->execute([$itemId]);
// Save order
$stmt = $pdo->prepare("INSERT INTO orders (user_id, item_id, price) VALUES (?, ?, ?)");
$stmt->execute([$userId, $itemId, $item['price']]);
echo "Order placed!";
}
private function chargeCard(string $card, float $amount): void
{
// Simulate some terrible payment logic
if (rand(0, 10) < 2) {
throw new Exception("Card declined!");
}
// Otherwise... money gone!
}
}That’s better. No wait… it’s not.
It’s absolutely dire, right?
You might be wondering, who even writes code like this anymore? Well, probably nobody — but I’ve worked at companies where this kind of hot diarrhoea is still bringing in serious revenue.
The problem is, maintaining it has become a costly nightmare: what used to take five minutes now drags on for five days, and every little change feels like a mountain to climb, or it impacts other areas of the system and breaks stuff randomly without any reason.
You get the idea. So, how do we modernise and refactor this monstrosity without tearing the whole thing down and starting from scratch?
Here’s how I’d do it.
Step 1: Refactor into Modules
The first step is to break down the tangled mess of legacy code into clear, manageable modules based on business domains — like orders, payments, users, and inventory.
It also sets a solid foundation for future changes, letting you swap out or rewrite parts without breaking everything. So let’s do that.
Order
class Order
{
public function validate(array $orderData): void
{
// Logic here...
}
public function calculateTotal(array $items): float
{
// Logic here...
}
public function save(array $orderData, float $total): void
{
// Logic here...
}
}Payment
class Payment
{
public function charge(string $method, float $amount): bool
{
// Logic here...
}
}They’d obviously be more modules (such as User and Item) but you get the idea! With these modules in place, we can rewrite the original code.
class OrderController
{
public function __construct(
private User $user,
private Item $item,
private Payment $payment,
private Order $order
) {}
public function placeOrder(int $userId, int $itemId): void
{
$user = $this->user->find($userId);
$item = $this->item->find($itemId);
if ($item['stock'] <= 0) {
throw new Exception("Out of stock!");
}
$this->payment->charge($user['card'], $item['price']);
$this->item->decrementStock($itemId);
$this->order->save($user['id'], $item['id'], $item['price']);
echo "Order placed!";
}
}
You’ve effectively transformed your monolith into a modular monolith — a single app made up of loosely coupled, well-defined pieces.
This gives you all the benefits of modular design (like easier maintenance, better code ownership, and simpler testing) while still running as a single deployable unit.
We can now switch out pieces of code easily.
It’s a safe middle ground that helps reduce risk and keeps things stable while you prepare for the next phase.
Step 2: Extract to Microservices (One Module at a Time)
With that in place, you can start peeling off modules into microservices — one at a time.
Or maybe… you don’t need to? Not yet, anyway. Maybe not until performance becomes a problem or teams start stepping on each other’s toes. But when the time comes, you’re ready.
Thanks to abstracting code into modules, the rest of your app won’t care when it packs its bags and moves to a separate service — because the interface stays exactly the same.
This slow-and-steady approach helps you avoid this….
… and gives you breathing room to get each service right before moving on to the next.
Bit by bit, you end up with a scalable, distributed system — built on top of a clean, maintainable core.
So, which module should you focus on first?
It’s simple really…
Extract the module that causes you the most pain.
But, what’s that look like?
It changes often: If a part of your system is constantly evolving, it’s a prime candidate. Frequent changes increase the risk of breaking other parts of the system. Extracting it reduces that blast radius.
It’s slow or has scaling issues: If a module is CPU- or IO-intensive (like generating invoices, processing images, or importing large CSVs), you can move it out to scale independently. This also helps reduce load on your core app.
It has external dependencies: Modules that already talk to third-party APIs (Stripe, SendGrid, Shopify) make great microservices because you’re already managing some separation. Wrapping them into a clean interface keeps your core logic cleaner.
It’s also worth noting that it’s important to choose wisely. You shouldn’t pick a module where:
It has core business logic that everything depends on. It’s too tightly coupled.
It has anything you don’t fully understand yet.
Shares state or side-effects with other modules.
With that said, let’s shift our Order module so it defers to a microservice.
class Order
{
public function save(int $userId, int $itemId, float $price): void
{
$response = $this->request('POST', 'http://orders.service/api/orders', [
'user_id' => $userId,
'item_id' => $itemId,
'price' => $price,
]);
if ($response['status'] !== 200) {
throw new Exception('Order service failed: ' . $response['message']);
}
}
}
Now from the rest of your systems perspective.
$this->order->save($user['id'], $item['id'], $item['price']);Conclusion
This isn’t gospel, just a strategy I use when it makes sense to do so.
As Martin Fowler puts it:
Ideally, software should consist of clear components that can be replaced independently. Legacy systems rarely exhibit this characteristic, so there's considerable work in figuring out how to break it down into manageable pieces.
With these pieces isolated, we can start replacing them. Since these components are small, there isn't so much risk involved when we introduce the new software.
Once working, the business can reap the value from these new components, allowing earlier return on the investment. We also learn more about how to replace the legacy system, and its consequences for the business, which helps us make better decisions as the modernisation continues.
If performance starts tanking, or your team’s spending more time firefighting than building, then gradually peeling away legacy pieces using the Strangler Fig Pattern can be a lifesaver.
You don’t have to rip the whole tree out. You let the new system grow around the old one, one service at a time, until the legacy core quietly fades away.



