行业动态
www.freecodecamp.org ·
@Hugo Teijiz
迁移前如何重构遗留应用
教程介绍在迁移遗留应用前进行重构的方法,包括分离业务规则、引入接缝等,以降低迁移风险。
AI推荐理由 freeCodeCamp教程,详细讲解迁移前重构策略,实用性强,但非AI核心内容。
65
正文
当团队决定迁移遗留应用程序时,通常会有压力开始移动代码。移动数据库、API或UI。将应用程序迁移到新的框架、运行时、云提供商或架构。这听起来合理,但有一个问题。如果当前系统在同一个模块中混合了业务规则、持久化、基础设施、外部集成和编排,迁移就会变得比需要的困难得多。你不仅仅是在移动软件。你是在尝试移动多个在多年开发中纠缠在一起的职责。这就是为什么我经常更喜欢在迁移之前进行重构。不是为了让遗留系统变得漂亮或重新设计一切。也绝对不是把准备阶段变成另一次重写。目标要窄得多:改变结构,使重要的行为能够独立移动。在此工作流程的前几个步骤中,我们首先尝试理解代码库,然后使用特征测试来保护我们即将更改的行为。现在你将学习如何开始更改结构。在本教程中,我将通过以下方式向你展示如何为迁移准备遗留应用程序:选择迁移边界,将业务规则与基础设施分离,引入接缝,隔离副作用,围绕外部系统创建适配器,减少依赖方向问题,提取内聚的应用程序行为,在整个重构过程中使用特征测试,使用AI而不让它盲目地重新设计系统,以及知道应用程序何时准备好开始迁移。示例使用TypeScript,但该过程适用于大多数语言和架构。目标不是:遗留应用程序 ↓ 完美架构 相反,它是:遗留应用程序 ↓ 迁移友好的结构 ↓ 增量迁移 这种差异可以节省大量不必要的工作。先决条件 你应该熟悉:阅读现有代码库 TypeScript或类似语言 单元和集成测试 依赖注入 接口和适配器 基本软件架构 增量重构 你还应该对你计划修改的功能有一些行为保护。这可能包括:特征测试 集成测试 契约测试 或其他可靠的方法来验证现有行为。没有这种保护的重构是可能的。但区分结构改进和意外行为变化也要困难得多。目录 先决条件 为什么迁移问题往往在迁移之前就开始了 在重构之前选择迁移边界 不要重构整个应用程序 将业务规则与基础设施分离 在硬依赖周围引入接缝 将副作用与决策逻辑隔离 将外部系统置于适配器之后 在不重建一切的情况下改善依赖方向 提取内聚的应用程序边界 在重构期间保持行为测试运行 如何在结构重构期间使用AI 不要过早要求AI设计目标架构 如何知道一个功能何时准备好迁移 实用的迁移前重构工作流程 迁移前不要重构什么 重构是准备,不是迁移 结论 为什么迁移问题往往在迁移之前就开始了 想象一下,你需要迁移一个订单处理应用程序。你检查主服务,发现类似这样的东西:async function processOrder(orderId: string) { const connection = await mysql.getConnection(); const [rows] = await connection.query( "SELECT * FROM orders WHERE id = ?", [orderId] ); const order = rows[0]; if (!order) { throw new Error("Order not found"); } if (order.customer_type === "PREMIUM") { order.total = order.total * 0.9; } if ( order.country === "AR" && order.payment_method === "TRANSFER" ) { order.total -= 500; } await connection.query( "UPDATE orders SET total = ?, status = ? WHERE id = ?", [order.total, "PROCESSED", order.id] ); await paymentProvider.createPayment({ orderId: order.id, amount: order.total, }); await eventBus.publish("order.processed", { id: order.id, total: order.total, }); await emailClient.send({ to: order.customer_email, template: "order-processed", }); return order; } 假设迁移目标是:MySQL → PostgreSQL 旧运行时 → 新运行时 遗留API → 新服务 明显的诱惑是开始将这个函数翻译成目标技术栈。但你到底在迁移什么?该函数包含:数据库访问 业务规则 状态转换 支付集成 事件发布 电子邮件传递 应用程序编排 现在更改数据库可能会影响定价,而更改支付客户端可能会影响持久化。将函数移动到另一个服务意味着一次移动其所有依赖项。迁移的困难部分是由当前结构造成的。因此,在迁移之前,你需要创建足够的分离,以便这些关注点可以独立移动。在重构之前选择迁移边界 不要从:让我们清理应用程序开始。从:我们想先迁移什么?开始。假设你决定第一个功能将是处理订单。这为你的重构提供了一个边界。现在你可以映射:输入:orderId 业务行为:加载订单 计算调整 标记为已处理 副作用:持久化订单 创建支付 发布事件 发送电子邮件 输出:已处理的订单 这比决定重构:src/services/ 要有用得多,因为文件夹不一定是业务边界。当你可以推理功能时,迁移效果更好。例如:处理订单 取消订单 生成发票 注册客户 续订订阅 每个都可能成为一个迁移单元。不要重构整个应用程序 一旦你开始识别架构问题,修复所有问题就会变得诱人。你可能会注意到:循环依赖 重复的存储库 全局
配置 大型服务 静态助手 直接数据库访问 不一致的错误处理 混合领域模型 所有这些可能都值得关注,但迁移并不要求所有技术债务消失。假设你的目标是订单处理功能。一个有用的规则是只重构那些阻止此功能安全移动的内容。例如:问题:订单处理直接调用MySQL。相关?是。问题:报告模块使用不一致的日期格式。相关?可能不。问题:订单处理直接调用支付SDK。相关?是。问题:管理UI包含重复的CSS。相关?否。这可以防止准备阶段变成一个无休止的清理项目。遗留现代化需要范围纪律。将业务规则与基础设施分离 最有价值的结构更改通常是将业务行为与技术特定细节分离。以这段代码为例:async function processOrder(orderId: string) { const order = await mysqlOrders.find(orderId); if (order.customerType === "PREMIUM") { order.total *= 0.9; } if ( order.country === "AR" && order.paymentMethod === "TRANSFER" ) { order.total -= 500; } await mysqlOrders.update(order); await stripe.createPayment({ orderId: order.id, amount: order.total, }); } 定价行为本身不需要MySQL或Stripe。你可以提取它:type Order = { id: string; total: number; customerType: "STANDARD" | "PREMIUM"; country: string; paymentMethod: "CARD" | "TRANSFER"; }; function calculateOrderTotal(order: Order): number { let total = order.total; if (order.customerType === "PREMIUM") { total *= 0.9; } if ( order.country === "AR" && order.paymentMethod === "TRANSFER" ) { total -= 500; } return Math.max(total, 0); } 现在:定价行为不再与:MySQL Stripe 耦合。这不需要完整的领域驱动重新设计。这只是一个有用的分离。下一步迁移可以替换基础设施,同时保持此行为不变。在硬依赖周围引入接缝 遗留代码通常包含在测试或迁移代码中不易替换的依赖项。例如:class OrderService { async process(orderId: string) { const client = new LegacyDatabaseClient(); const order = await client.findOrder(orderId); // ... } } 数据库依赖是在方法内部创建的。这使得替换变得困难。一个小的准备重构可以引入一个接缝:interface OrderRepository { findById(id: string): Promise; save(order: Order): Promise; } 然后:class OrderService { constructor( private readonly orders: OrderRepository ) {} async process(orderId: string) { const order = await this.orders.findById(orderId); if (!order) { throw new Error("Order not found"); } // existing behavior } } 现在现有的MySQL实现可以满足接口:class MySqlOrderRepository implements OrderRepository { async findById(id: string) { // existing MySQL behavior } async save(order: Order) { // existing MySQL behavior } } 稍后,迁移可以引入:class PostgresOrderRepository implements OrderRepository { // new implementation } 注意我们没有改变什么:我们没有改变业务行为。我们改变了依赖项的可替换性。这正是有助于迁移的重构类型。将副作用与决策逻辑隔离 另一个有用的分离是:决策 和:执行 之间。假设取消当前看起来像这样:async function cancelOrder(order: Order) { if (order.status === "SHIPPED") { throw new Error("Cannot cancel shipped order"); } order.status = "CANCELLED"; await orders.save(order); await inventory.release(order.id); await payment.refund(order.id); await audit.log("ORDER_CANCELLED", order.id); } 这里有两个不同的职责。业务决策:这个订单可以取消吗?它的新状态应该是什么?以及操作效果:持久化 释放库存 退款 审计 你可以先提取决策:function cancelOrderState(order: Order): Order { if (order.status === "SHIPPED") { throw new Error("Cannot cancel shipped order"); } return { ...order, status: "CANCELLED", }; } 然后编排仍然存在:async function cancelOrder(order: Order) { const cancelled = cancelOrderState(order); await orders.save(cancelled); await inventory.release(cancelled.id); await payment.refund(cancelled.id); await audit.log("ORDER_CANCELLED", cancelled.id); return cancelled; } 行为仍然相同,但现在状态转换可以独立测试和迁移。如果目标架构改变了副作用的执行方式,这一点很重要。例如,未来的版本可能使用:事务性发件箱 事件驱动工作流 队列 工作流引擎 你不需要现在就引入这些机制。你只需要阻止当前的决策逻辑直接依赖它们。将外部系统置于适配器之后 外部SDK经常深入遗留代码。例如:const result = await stripe.paymentIntents.create({ amount: order.total, currency: "usd", metadata: { orderId: order.id, }, }); 如果数十个应用程序模块直接依赖Stripe SDK,替换或重新定位支付处理将变得困难。相反,创建一个应用程序级边界:type PaymentRequest = { orderId: string; amount: number; }; type PaymentResult = { paymentId: string; }; interface PaymentGateway { charge( request: PaymentRequest ): Promise; } Stripe适配器包含提供商特定的细节:class StripePaymentGateway implements PaymentGateway { async charge( request: PaymentRequest ): Promise { const result = await stripe.paymentIntents.create({ amount: request.amount, currency: "usd", metadata: { orderId: request.orderId, }, }); return { paymentId: result.id, }; } } 应用程序现在知道:PaymentGateway 而不是:Stripe SDK 这对迁移很有用,因为提供商特定的代码被本地化了。同样的模式适用于:电子邮件提供商
消息代理 云存储 ERP集成 CRM API 身份提供商 搜索引擎 适配器之所以有价值,不是因为接口时髦,而是因为它创建了一个你可以移动的边界。
在不重建一切的情况下改善依赖方向
遗留系统通常具有这样的依赖关系:
业务逻辑 ↓ 数据库SDK ↓ 框架工具
这使得基础设施难以替换。你不一定需要实现完整的整洁架构。你只需要在迁移需要的地方改善依赖方向。例如:
之前:OrderService ↓ MySQL
之后:OrderService ↓ OrderRepository ↑ MySqlOrderRepository
应用程序依赖于抽象。基础设施实现它。支付也可以这样:
OrderService ↓ PaymentGateway ↑ StripePaymentGateway
以及消息传递:
OrderService ↓ OrderEvents ↑ KafkaOrderEvents
现在替换基础设施不再需要重写应用程序服务。这是重要的成果。
提取一个有凝聚力的应用边界
经过几次小的重构后,该能力可能开始看起来像这样:
interface OrderRepository {
findById(id: string): Promise;
save(order: Order): Promise;
}
interface PaymentGateway {
charge(request: { orderId: string; amount: number; }): Promise;
}
interface OrderEvents {
processed(order: Order): Promise;
}
class ProcessOrder {
constructor(
private readonly orders: OrderRepository,
private readonly payments: PaymentGateway,
private readonly events: OrderEvents
) {}
async execute(orderId: string) {
const order = await this.orders.findById(orderId);
if (!order) {
throw new Error("Order not found");
}
const total = calculateOrderTotal(order);
const processed: Order = {
...order,
total,
status: "PROCESSED",
};
await this.orders.save(processed);
await this.payments.charge({
orderId: processed.id,
amount: processed.total,
});
await this.events.processed(processed);
return processed;
}
}
这不一定是最終架构。这很重要。我们不是说:这就是应用程序应该永远的样子。我们只是说:这个能力现在有了使迁移更容易的边界。基础设施可以独立变化。业务规则是可测试的。编排是可见的。外部契约是明确的。这足以开始考虑迁移。
在重构期间保持行为测试运行
这是上一步中的特征测试变得有用的地方。假设原始行为受到保护:
it("preserves premium order processing behavior", async () => {
const result = await processOrder("order-1");
expect(result.total).toBe(9000);
expect(result.status).toBe("PROCESSED");
expect(payment.charge).toHaveBeenCalledWith({
orderId: "order-1",
amount: 9000,
});
expect(events.processed).toHaveBeenCalled();
});
现在你可以将:直接数据库访问改为:仓库并运行测试。然后你可以将:直接支付SDK改为:支付适配器并运行测试。然后提取:定价逻辑并运行测试。节奏变成:
小的结构变化 ↓ 测试 ↓ 小的结构变化 ↓ 测试 ↓ 小的结构变化 ↓ 测试
这很重要,因为当行为变化不同时发生时,结构重构更容易推理。如果一个小变化后测试失败,可能的原因很窄。如果两周重写后测试失败,可能的原因几乎是所有东西。
如何在结构重构期间使用AI
AI在这个阶段可以帮很多忙。但有用的提示不同于:使用整洁架构重构这个应用程序。相反,给模型一个受限的转换。例如:
这个服务目前直接访问MySQL。我想引入一个OrderRepository接缝而不改变可观察行为。任务:
1. 识别此服务使用的每个数据库操作,
2. 提出所需的最小仓库接口,
3. 将现有数据库调用移到适配器后面,
4. 在相关处保留返回值、错误和调用顺序,
5. 不要改变业务规则,
6. 不要引入额外的抽象。
在生成代码之前解释每个结构变化。
这给了AI一个更窄的工作。另一个有用的请求是:
比较此重构前后的实现。识别可能改变的任何可观察行为。具体检查:
- 异常,
- 返回值,
- 副作用,
- 副作用的顺序,
- 空值处理,
- 事务边界,
- 重试行为。
不要因为代码看起来相似就假设等价。
这是AI作为第二审阅者有价值的地方。它可以比你手动扫描大变化更快地检查差异。但测试仍然提供更强的证据。
不要过早让AI设计目标架构
AI非常擅长识别常见的架构模式。但这也可能很危险。给模型一个大型遗留服务并问:应该如何现代化?你可能会收到:微服务 事件驱动架构 CQRS 仓库模式 领域事件 消息代理 API网关 分布式缓存
所有这些都是合法的技术或模式,但没有一个是自动合理的。在选择目标架构之前,你需要约束。例如:部署频率 团队规模 事务要求 延迟 故障容忍 数据所有权 集成边界 运营成熟度 流量 成本 监管要求
具有良好边界的单体可能比微服务更好的目标。同步工作流可能比事件驱动处理更好。数据库迁移可能不需要改变领域模型。架构应该跟随约束,而不是模式识别。使用AI评估选项。不要让熟悉模式的存在成为采用它的理由。
如何知道一个能力何时准备好迁移
在某个时候,你必须停止重构。这个决定很重要。
你不需要完美的代码。当你能清晰回答这些问题时,一个能力通常更接近迁移就绪。
我能描述它的输入吗?例如:orderId 客户请求负载 事件
我能描述它的输出吗?例如:已处理的订单 HTTP响应 事件 数据库更改
它的重要业务规则可见吗?它们不必完美,但你应该知道它们在哪里。
外部依赖明确吗?例如:OrderRepository PaymentGateway OrderEvents EmailSender
基础设施可以被替换吗?如果替换MySQL需要改变定价逻辑,边界可能还没有准备好。
重要行为受到保护吗?你应该有足够的测试来检测意外变化。
你知道副作用吗?例如:持久化订单 创建支付 发布事件 发送电子邮件
主要未知项有文档记录吗?一些不确定性可能仍然存在。但它不应该是不可见的。
如果你能回答这些问题,你可能就有足够的结构来开始迁移该能力。
一个实用的迁移前重构工作流
以下是我会使用的工作流。
1. 选择一个能力
不要重构整个应用程序。选择:处理订单 生成发票 续订订阅
2. 确认行为保护
在结构变化之前,确保关键行为有测试。捕获:输出 状态转换 副作用 错误 契约
3. 识别迁移阻塞器
寻找耦合,例如:直接数据库访问 提供商SDK 全局状态 框架特定对象 静态依赖 共享可变状态
4. 尽可能提取纯业务逻辑
将计算和决策从基础设施移开。例如:计算价格 验证转换 选择状态 计算佣金
5. 引入接缝
围绕以下创建最小边界:数据库 支付 事件 电子邮件 存储 外部API
不要在没有迁移理由的情况下创建抽象。
6. 本地化基础设施
将技术特定行为移入适配器。例如:MySqlOrderRepository StripePaymentGateway KafkaOrderEvents SendGridEmailSender
7. 使编排可见
目标是使序列可理解的能力:加载 ↓ 决定 ↓ 持久化 ↓ 执行副作用 ↓ 返回
8. 每一步后运行行为测试
不要将十个重构批处理在一起。保持变化小。
9. 比较前后
检查:输入 输出 错误 副作用 数据形状 排序 事务
10. 当迁移成为可能时停止
不要因为代码仍然可以更干净而继续重构。它总是可以。目标是迁移就绪。
迁移前不要重构什么
在这个阶段,我通常会避免改变几件事,除非它们直接阻塞迁移。
到处命名
你可能不喜欢数百个旧名称。但重命名一切会产生大差异,几乎没有迁移价值。
格式化整个仓库
同样的问题。噪音使行为变化更难审查。
替换每个模式
遗留系统可能包含:单例 服务定位器 静态工具 大类
有些可能暂时保留。修复那些跨越迁移边界的。
重写稳定算法
如果旧计算丑陋但受保护且隔离,可能更安全的是先移动它,以后再改进。
修复每个发现的错误
这一点尤其重要。如果你在准备迁移时发现错误,记录它。然后决定修复它是否属于同一变化。混合:结构重构 + 行为修正 + 平台迁移会使失败更难理解。有时正确的答案是:保留错误 迁移 有意修复错误 之后
这听起来不舒服。但迁移期间意外行为变化可能更危险。
重构是准备,不是迁移
迁移前重构很容易变成无休止的架构项目。你开始于:我们需要隔离数据库。然后:我们应该重新设计领域模型。然后:也许我们应该引入事件。然后:如果我们这样做,也许这应该成为微服务。几个月后,什么也没有迁移。重构已经成为项目。这也是一个失败模式。
目标应该保持具体。
之前:ProcessOrder ├── MySQL ├── 定价规则 ├── Stripe ├── Kafka ├── 电子邮件 └── 框架内部
之后:ProcessOrder ├── OrderRepository ├── 定价规则 ├── PaymentGateway ├── OrderEvents └── EmailSender
这可能就足够了。现在你有选择。你可以迁移:MySQL → PostgreSQL 而不重新设计定价。你可以替换:Stripe适配器而不改变订单编排。你可以移动:ProcessOrder到另一个运行时,同时保留其契约。重构创造了选项。这就是价值。
结论
当几种类型的变化同时发生时,遗留迁移变得有风险。你改变:行为 架构 基础设施 运行时 数据 部署,然后试图理解哪个变化导致了失败。更安全的方法是在迁移开始前减少这种不确定性。首先理解能力,然后描述其行为,然后在不有意改变其功能的情况下改变其结构。围绕依赖创建边界。将业务决策与基础设施分离。本地化外部系统。保持副作用可见。在每个结构变化后运行行为测试。当能力变得可移动时停止重构。
顺序变成:理解 ↓ 描述 ↓ 重构 ↓ 迁移
AI可以使重构阶段大大加快。它可以识别依赖,提取接口,将调用移到适配器后面,比较实现,并审查大差异。但更快的重构不会消除对架构判断的需要。它使这种判断更重要。因为目标不是产生遗留系统最干净的版本。目标是创建足够的结构来安全移动它。一旦你可以在不改变行为的情况下改变基础设施,迁移就不再像重写。它开始看起来像一系列受控的变化。
在不改变行为的情况下改变基础设施,迁移就不再像重写。它开始看起来像一系列受控的变化。
查看原文(中文正文为 AI 翻译)
The moment a team decides to migrate a legacy application, there's usually pressure to start moving code. Move the database, the API, or the UI. Move the application to a new framework, runtime, cloud provider, or architecture. That sounds reasonable, but there's a problem. If the current system mixes business rules, persistence, infrastructure, external integrations, and orchestration inside the same modules, migration becomes much harder than it needs to be. You're not just moving software. You're trying to move several responsibilities that have become entangled over years of development. This is why I often prefer to refactor before migrating. Not to make the legacy system beautiful or redesign everything. And definitely not to turn the preparation phase into another rewrite. The goal is much narrower: change the structure enough that important behavior can move independently. In the previous steps of this workflow, we first tried to understand the codebase and then used characterization tests to protect the behavior we were about to change. Now you'll learn how you can start changing the structure. In this tutorial, I'll show you how to prepare a legacy application for migration by: choosing a migration boundary, separating business rules from infrastructure, introducing seams, isolating side effects, creating adapters around external systems, reducing dependency direction problems, extracting cohesive application behavior, using characterization tests throughout the refactor, using AI without letting it redesign the system blindly, and knowing when the application is ready to start migrating. The examples use TypeScript, but the process applies to most languages and architectures. The objective is not: legacy application ↓ perfect architecture Instead, it's: legacy application ↓ migration-friendly structure ↓ incremental migration That difference can save a lot of unnecessary work. Prerequisites You should be comfortable with: reading an existing codebase TypeScript or a similar language unit and integration testing dependency injection interfaces and adapters basic software architecture incremental refactoring You should also have some behavioral protection around the capability you plan to modify. That may include: characterization tests integration tests contract tests or another reliable way to verify existing behavior. Refactoring without that protection is possible. But it's also much harder to distinguish a structural improvement from an accidental behavioral change. Table of Contents Prerequisites Why Migration Problems Often Start Before the Migration Choose a Migration Boundary Before Refactoring Don't Refactor the Entire Application Separate Business Rules from Infrastructure Introduce Seams Around Hard Dependencies Isolate Side Effects from Decision Logic Put External Systems Behind Adapters Improve Dependency Direction Without Rebuilding Everything Extract a Cohesive Application Boundary Keep Behavioral Tests Running During the Refactor How to Use AI During Structural Refactoring Don't Ask AI to Design the Target Architecture Too Early How to Know When a Capability Is Ready to Migrate A Practical Pre-Migration Refactoring Workflow What Not to Refactor Before Migration Refactoring Is Preparation, Not the Migration Conclusion Why Migration Problems Often Start Before the Migration Imagine you need to migrate an order-processing application. You inspect the main service and find something like this: async function processOrder(orderId: string) { const connection = await mysql.getConnection(); const [rows] = await connection.query( "SELECT * FROM orders WHERE id = ?", [orderId] ); const order = rows[0]; if (!order) { throw new Error("Order not found"); } if (order.customer_type === "PREMIUM") { order.total = order.total * 0.9; } if ( order.country === "AR" && order.payment_method === "TRANSFER" ) { order.total -= 500; } await connection.query( "UPDATE orders SET total = ?, status = ? WHERE id = ?", [order.total, "PROCESSED", order.id] ); await paymentProvider.createPayment({ orderId: order.id, amount: order.total, }); await eventBus.publish("order.processed", { id: order.id, total: order.total, }); await emailClient.send({ to: order.customer_email, template: "order-processed", }); return order; } Suppose the migration goal is: MySQL → PostgreSQL Old runtime → New runtime Legacy API → New service The obvious temptation is to begin translating this function into the target stack. But what exactly are you migrating? The function contains: database access business rules state transition payment integration event publication email delivery application orchestration Changing the database now risks affecting pricing, while changing the payment client risks affecting persistence. And moving the function into another service means moving all of its dependencies at once. The migration difficulty is partly caused by the current structure. So before migrating, you'll want to create enough separation that those concerns can move independently. Choose a Migration Boundary Before Refactoring Don't begin with: Let's clean up the application. Begin with: What do we want to migrate first? Suppose you decide the first capability will be Process Order. That gives your refactor a boundary. Now you can map: Input: orderId Business behavior: load order calculate adjustments mark as processed Side effects: persist order create payment publish event send email Output: processed order This is much more useful than deciding to refactor: src/services/ because a folder isn't necessarily a business boundary. Migration works better when you can reason about capabilities. For example: Process Order Cancel Order Generate Invoice Register Customer Renew Subscription Each can potentially become a migration unit. Don't Refactor the Entire Application Once you start identifying architectural problems, it becomes tempting to fix all of them. You may notice: circular dependencies duplicated repositories global configuration large services static helpers direct database access inconsistent error handling mixed domain models All of those may deserve attention, but the migration doesn't require all technical debt to disappear. Suppose your target is the order-processing capability. A useful rule is to refactor only what prevents this capability from moving safely. For example: Problem: Order processing calls MySQL directly. Relevant? Yes. Problem: The reporting module uses inconsistent date formatting. Relevant? Probably not. Problem: Order processing calls the payment SDK directly. Relevant? Yes. Problem: The admin UI contains duplicated CSS. Relevant? No. This prevents preparation from becoming an open-ended cleanup project. Legacy modernization needs scope discipline. Separate Business Rules from Infrastructure The most valuable structural change is often separating business behavior from technology-specific details. Take this code: async function processOrder(orderId: string) { const order = await mysqlOrders.find(orderId); if (order.customerType === "PREMIUM") { order.total *= 0.9; } if ( order.country === "AR" && order.paymentMethod === "TRANSFER" ) { order.total -= 500; } await mysqlOrders.update(order); await stripe.createPayment({ orderId: order.id, amount: order.total, }); } The pricing behavior itself doesn't need MySQL or Stripe. You can extract it: type Order = { id: string; total: number; customerType: "STANDARD" | "PREMIUM"; country: string; paymentMethod: "CARD" | "TRANSFER"; }; function calculateOrderTotal(order: Order): number { let total = order.total; if (order.customerType === "PREMIUM") { total *= 0.9; } if ( order.country === "AR" && order.paymentMethod === "TRANSFER" ) { total -= 500; } return Math.max(total, 0); } Now: pricing behavior is no longer coupled to: MySQL Stripe This doesn't require a complete domain-driven redesign. It's simply a useful separation. The next migration step can replace infrastructure while leaving this behavior unchanged. Introduce Seams Around Hard Dependencies Legacy code often contains dependencies that can't easily be replaced in tests or migration code. For example: class OrderService { async process(orderId: string) { const client = new LegacyDatabaseClient(); const order = await client.findOrder(orderId); // ... } } The database dependency is created inside the method. That makes substitution difficult. A small preparatory refactor can introduce a seam: interface OrderRepository { findById(id: string): Promise; save(order: Order): Promise; } Then: class OrderService { constructor( private readonly orders: OrderRepository ) {} async process(orderId: string) { const order = await this.orders.findById(orderId); if (!order) { throw new Error("Order not found"); } // existing behavior } } Now the existing MySQL implementation can satisfy the interface: class MySqlOrderRepository implements OrderRepository { async findById(id: string) { // existing MySQL behavior } async save(order: Order) { // existing MySQL behavior } } Later, the migration can introduce: class PostgresOrderRepository implements OrderRepository { // new implementation } Notice what we didn't change: we didn't change the business behavior. We changed the replaceability of a dependency. That's exactly the kind of refactoring that helps migration. Isolate Side Effects from Decision Logic Another useful separation is between: deciding and: performing Suppose cancellation currently looks like this: async function cancelOrder(order: Order) { if (order.status === "SHIPPED") { throw new Error("Cannot cancel shipped order"); } order.status = "CANCELLED"; await orders.save(order); await inventory.release(order.id); await payment.refund(order.id); await audit.log("ORDER_CANCELLED", order.id); } There are two different responsibilities here. The business decision: Can this order be cancelled? What should its new state be? And the operational effects: persist release inventory refund audit You could first extract the decision: function cancelOrderState(order: Order): Order { if (order.status === "SHIPPED") { throw new Error("Cannot cancel shipped order"); } return { ...order, status: "CANCELLED", }; } Then orchestration remains: async function cancelOrder(order: Order) { const cancelled = cancelOrderState(order); await orders.save(cancelled); await inventory.release(cancelled.id); await payment.refund(cancelled.id); await audit.log("ORDER_CANCELLED", cancelled.id); return cancelled; } The behavior is still the same, but now the state transition can be tested and migrated independently. That matters if the target architecture changes how side effects are executed. For example, the future version might use: transactional outbox event-driven workflow queue workflow engine You don't need to introduce those mechanisms yet. You only need to stop the current decision logic from depending directly on them. Put External Systems Behind Adapters External SDKs often leak deeply into legacy code. For example: const result = await stripe.paymentIntents.create({ amount: order.total, currency: "usd", metadata: { orderId: order.id, }, }); If dozens of application modules depend directly on the Stripe SDK, replacing or relocating payment processing becomes difficult. Create an application-level boundary instead: type PaymentRequest = { orderId: string; amount: number; }; type PaymentResult = { paymentId: string; }; interface PaymentGateway { charge( request: PaymentRequest ): Promise; } The Stripe adapter contains the provider-specific details: class StripePaymentGateway implements PaymentGateway { async charge( request: PaymentRequest ): Promise { const result = await stripe.paymentIntents.create({ amount: request.amount, currency: "usd", metadata: { orderId: request.orderId, }, }); return { paymentId: result.id, }; } } The application now knows about: PaymentGateway instead of: Stripe SDK This is useful for migration because provider-specific code is localized. The same pattern works for: email providers message brokers cloud storage ERP integrations CRM APIs identity providers search engines The adapter isn't valuable because interfaces are fashionable. It's valuable because it creates a boundary you can move. Improve Dependency Direction Without Rebuilding Everything Legacy systems often have dependency relationships such as: business logic ↓ database SDK ↓ framework utilities That makes infrastructure difficult to replace. You don't necessarily need to implement full Clean Architecture. You only need to improve dependency direction where migration requires it. For example: Before: OrderService ↓ MySQL After: OrderService ↓ OrderRepository ↑ MySqlOrderRepository The application depends on an abstraction. The infrastructure implements it. The same can happen with payments: OrderService ↓ PaymentGateway ↑ StripePaymentGateway and messaging: OrderService ↓ OrderEvents ↑ KafkaOrderEvents Now replacing infrastructure no longer requires rewriting the application service. That's the important outcome. Extract a Cohesive Application Boundary After several small refactors, the capability may start to look like this: interface OrderRepository { findById(id: string): Promise; save(order: Order): Promise; } interface PaymentGateway { charge(request: { orderId: string; amount: number; }): Promise; } interface OrderEvents { processed(order: Order): Promise; } class ProcessOrder { constructor( private readonly orders: OrderRepository, private readonly payments: PaymentGateway, private readonly events: OrderEvents ) {} async execute(orderId: string) { const order = await this.orders.findById(orderId); if (!order) { throw new Error("Order not found"); } const total = calculateOrderTotal(order); const processed: Order = { ...order, total, status: "PROCESSED", }; await this.orders.save(processed); await this.payments.charge({ orderId: processed.id, amount: processed.total, }); await this.events.processed(processed); return processed; } } This isn't necessarily the final architecture. That's important. We aren't claiming: This is how the application should look forever. We're just saying: This capability now has boundaries that make migration easier. The infrastructure can change independently. The business rules are testable. The orchestration is visible. And the external contracts are explicit. That's enough to start considering migration. Keep Behavioral Tests Running During the Refactor This is where the characterization tests from the previous step become useful. Suppose the original behavior was protected with: it("preserves premium order processing behavior", async () => { const result = await processOrder("order-1"); expect(result.total).toBe(9000); expect(result.status).toBe("PROCESSED"); expect(payment.charge).toHaveBeenCalledWith({ orderId: "order-1", amount: 9000, }); expect(events.processed).toHaveBeenCalled(); }); Now you can change: direct database access into: repository and run the test. Then change: direct payment SDK into: payment adapter and run the test. Then extract: pricing logic and run the test. The rhythm becomes: small structural change ↓ test ↓ small structural change ↓ test ↓ small structural change ↓ test This matters because structural refactoring is much easier to reason about when behavioral changes aren't happening at the same time. If a test fails after one small change, the possible cause is narrow. If a test fails after a two-week rewrite, the possible cause is almost everything. How to Use AI During Structural Refactoring AI can help a lot during this phase. But the useful prompts are different from: Refactor this application using Clean Architecture. Instead, give the model a constrained transformation. For example: This service currently accesses MySQL directly. I want to introduce an OrderRepository seam without changing observable behavior. Tasks: 1. identify every database operation used by this service, 2. propose the smallest repository interface needed, 3. move existing database calls behind an adapter, 4. preserve return values, errors, and call order where relevant, 5. do not change business rules, 6. do not introduce additional abstractions. Explain every structural change before generating code. That gives AI a much narrower job. Another useful request is: Compare the implementation before and after this refactor. Identify any observable behavior that may have changed. Check specifically: - exceptions, - return values, - side effects, - ordering of side effects, - null handling, - transaction boundaries, - retry behavior. Do not assume equivalence because the code looks similar. This is where AI can be valuable as a second reviewer. It can inspect differences faster than you can manually scan large changes. But the tests still provide stronger evidence. Don't Ask AI to Design the Target Architecture Too Early AI is very good at recognizing common architecture patterns. But that can also be dangerous. Give a model a large legacy service and ask: How should this be modernized? and you may receive: microservices event-driven architecture CQRS repository pattern domain events message broker API gateway distributed cache All of those are legitimate technologies or patterns, but none of them are automatically justified. Before choosing a target architecture, you need constraints. For example: deployment frequency team size transactional requirements latency failure tolerance data ownership integration boundaries operational maturity traffic cost regulatory requirements A monolith with good boundaries may be a better target than microservices. A synchronous workflow may be better than event-driven processing. And a database migration may not require changing the domain model. Architecture should follow constraints, not pattern recognition. Use AI to evaluate options. Don't let the presence of a familiar pattern become the reason to adopt it. How to Know When a Capability Is Ready to Migrate At some point, you have to stop refactoring. And that decision matters. You don't need perfect code. A capability is usually much closer to migration-ready when you can answer these questions clearly. Can I Describe its Inputs? For example: orderId customer request payload event Can I Describe its Outputs? For example: processed order HTTP response event database change Are its Important Business Rules Visible? They don't have to be perfect, but you should know where they live. Are External Dependencies Explicit? For example: OrderRepository PaymentGateway OrderEvents EmailSender Can Infrastructure Be Substituted? If replacing MySQL requires changing pricing logic, the boundary is probably not ready. Are Important Behaviors Protected? You should have enough tests to detect accidental changes. Do You Know the Side Effects? For example: persist order create payment publish event send email Are Major Unknowns Documented? Some uncertainty may remain. But it shouldn't be invisible. If you can answer those questions, you probably have enough structure to begin migrating that capability. A Practical Pre-Migration Refactoring Workflow Here's the workflow I would use. 1. Choose One Capability Don't refactor the whole application. Pick: Process Order Generate Invoice Renew Subscription 2. Confirm Behavioral Protection Before structural changes, make sure critical behavior has tests. Capture: outputs state transitions side effects errors contracts 3. Identify Migration Blockers Look for coupling such as: direct database access provider SDKs global state framework-specific objects static dependencies shared mutable state 4. Extract Pure Business Logic Where Possible Move calculations and decisions away from infrastructure. For example: calculate price validate transition choose status calculate commission 5. Introduce Seams Create minimal boundaries around: database payments events email storage external APIs Don't create abstractions without a migration reason. 6. Localize Infrastructure Move technology-specific behavior into adapters. For example: MySqlOrderRepository StripePaymentGateway KafkaOrderEvents SendGridEmailSender 7. Make Orchestration Visible Aim for a capability where the sequence is understandable: load ↓ decide ↓ persist ↓ perform side effects ↓ return 8. Run Behavioral Tests After Every Step Don't batch ten refactors together. Keep the changes small. 9. Compare Before and After Check: inputs outputs errors side effects data shapes ordering transactions 10. Stop When Migration Becomes Possible Don't continue refactoring because the code could still be cleaner. It always could. The objective is migration readiness. What Not to Refactor Before Migration There are several things I would usually avoid changing during this phase unless they directly block migration. Naming Everywhere You may dislike hundreds of old names. But renaming everything produces large diffs with little migration value. Formatting the Entire Repository Same problem. Noise makes behavioral changes harder to review. Replacing Every Pattern A legacy system may contain: singletons service locators static utilities large classes Some may remain temporarily. Fix the ones crossing your migration boundary. Rewriting Stable Algorithms If an old calculation is ugly but protected and isolated, it may be safer to move it first and improve it later. Fixing Every Discovered Bug This one is especially important. If you discover a bug while preparing a migration, record it. Then decide whether fixing it belongs in the same change. Mixing: structural refactor + behavioral correction + platform migration makes failures much harder to understand. Sometimes the right answer is: preserve bug migrate fix bug intentionally afterward That sounds uncomfortable. But accidental behavior changes during migration can be much more dangerous. Refactoring Is Preparation, Not the Migration It's easy for pre-migration refactoring to become an endless architecture project. You start with: We need to isolate the database. Then: We should redesign the domain model. Then: Maybe we should introduce events. Then: If we're doing that, maybe this should become a microservice. Months later, nothing has migrated. The refactor has become the project. That's a failure mode, too. The objective should remain concrete. Before: ProcessOrder ├── MySQL ├── pricing rules ├── Stripe ├── Kafka ├── email └── framework internals After: ProcessOrder ├── OrderRepository ├── pricing rules ├── PaymentGateway ├── OrderEvents └── EmailSender That may be enough. Now you have choices. You can migrate: MySQL → PostgreSQL without redesigning pricing. You can replace: Stripe adapter without changing order orchestration. You can move: ProcessOrder into another runtime while preserving its contracts. The refactor created options. That's the value. Conclusion Legacy migrations become risky when several types of change happen at once. You change: behavior architecture infrastructure runtime data deployment and then try to understand which change caused the failure. A safer approach is to reduce that uncertainty before migration begins. First understand the capability, then characterize its behavior, and then change its structure without intentionally changing what it does. Create boundaries around dependencies. Separate business decisions from infrastructure. Localize external systems. Keep side effects visible. Run behavioral tests after every structural change. And stop refactoring when the capability becomes movable. The sequence becomes: Understand ↓ Characterize ↓ Refactor ↓ Migrate AI can make the refactoring phase dramatically faster. It can identify dependencies, extract interfaces, move calls behind adapters, compare implementations, and review large diffs. But faster refactoring doesn't remove the need for architectural judgment. It makes that judgment more important. Because the goal isn't to produce the cleanest version of the legacy system. The goal is to create just enough structure to move it safely. And once you can change the infrastructure without changing the behavior, migration stops looking like a rewrite. It starts looking like a sequence of controlled changes.