迁移前先重构:如何为遗留应用做迁移准备
团队一旦决定迁移遗留应用,往往就会面临"赶紧搬代码"的压力。
搬数据库、搬 API、搬 UI,把应用迁到新的 framework、runtime、云服务商或架构上。
听起来合理,但有个问题。
如果现有系统把业务规则、持久化、基础设施、外部集成和编排全部混在同一组模块里,迁移的难度会被不必要地放大。
你搬的不仅是软件本身,而是那些在多年开发中逐渐纠缠在一起的若干职责。
这就是我为什么通常倾向于先重构再迁移。不是为了把遗留系统改得漂亮,也不是全盘重新设计,更不是把准备阶段变成另一次重写。
目标要窄得多:调整结构到足够程度,让关键行为可以独立搬迁。
在这个流程的前几步中,我们先理解代码库,然后用 characterization tests 保护即将变更的行为。
下面,我们来看如何着手调整结构。
这篇教程将展示如何为遗留应用的迁移做准备:
确定迁移边界,
将业务规则与基础设施分离,
引入 seam,
隔离副作用,
为外部系统创建 adapter,
减少依赖方向问题,
提取内聚的应用行为,
在整个重构过程中使用 characterization tests,
合理使用 AI,避免让它盲目重新设计系统,
以及判断应用何时可以开始迁移。
示例使用 TypeScript,但这套流程适用于大多数语言和架构。
目标不是:
legacy application
↓
perfect architecture
而是:
legacy application
↓
migration-friendly structure
↓
incremental migration
这个区别能省掉大量无谓的工作。
前置条件
你需要熟悉以下内容:
阅读现有代码库
TypeScript 或类似语言
单元测试与集成测试
依赖注入
接口与适配器
基础软件架构
渐进式重构
此外,你还需要对计划修改的功能建立行为保护。
方式可能包括:
特征测试(characterization tests)
集成测试
契约测试(contract tests)
或者其他能可靠验证现有行为的方法。
没有这些保护也能重构,但你很难区分结构性改进和意外的行为变化,工作起来也会困难得多。
目录
为什么迁移问题往往在动手之前就埋下了
假设你需要迁移一个订单处理应用。
检查主服务时,你发现了类似这样的代码:
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
Old runtime → New runtime
Legacy API → New service
最直接的冲动,就是把这段函数原样翻译成目标技术栈。
但你真正要迁移的到底是什么?
这个函数里塞了:
database access
business rules
state transition
payment integration
event publication
email delivery
application orchestration
现在换数据库,可能波及定价逻辑;换支付客户端,又可能影响持久化。而把这个函数挪到另一个服务里,它的全部依赖也得跟着一起搬。
迁移之所以难,部分原因在于当前的代码结构。所以动手迁移之前,先做出足够的解耦,让各个关注点能独立移动。
先划定迁移边界,再动手重构
不要一上来就说:
先把整个应用清理干净。
而应该说:
我们想先迁移什么?
假设你决定第一个要迁移的能力是"处理订单",这样重构就有了明确的边界。
现在你可以画出这样的映射:
输入:
orderId
业务行为:
加载订单
计算调整项
标记为已处理
副作用:
持久化订单
创建支付
发布事件
发送邮件
输出:
处理后的订单
这比"把 src/services/ 重构一下"有用得多,因为文件夹不一定等于业务边界。
能围绕能力(capability)来推理,迁移才更高效。
比如:
处理订单
取消订单
生成发票
注册客户
续约订阅
每一项都可能成为一个迁移单元。
不要重构整个应用
一旦你开始发现架构问题,就会忍不住想全部修好。
你可能会注意到:
循环依赖
重复的 repository
全局配置
过大的 service
静态工具类
直接访问数据库
错误处理不一致
领域模型混杂
这些问题都值得处理,但迁移并不要求你清零所有技术债。
假设你的目标是"处理订单"这个能力,一个实用的原则是:只重构阻碍该能力安全迁移的部分。
比如:
问题:
订单处理直接调用 MySQL。
相关?
是。
问题:
报表模块日期格式不统一。
相关?
大概率不相关。
问题:
订单处理直接调用支付 SDK。
相关?
是。
问题:
管理后台 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);
}
这样一来:
pricing behavior
就不再和下面这些耦合在一起了:
MySQL
Stripe
这不需要彻底重构成领域驱动设计,只是做一次有用的分离。
接下来的迁移步骤就可以替换基础设施,而这段业务逻辑保持不变。
在硬依赖周围引入接缝
遗留代码里常常有一些依赖,很难在测试或迁移代码中替换掉。
比如:
class OrderService {
async process(orderId: string) {
const client = new LegacyDatabaseClient();
const order = await client.findOrder(orderId);
// ...
}
}
数据库依赖是在方法内部创建的。
这就导致替换起来很困难。
只需一个小规模的前置重构,就能引入一个接缝:
interface OrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}
然后:
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
}
注意我们没改什么:业务行为没有变化。我们改变的是依赖的可替换性。
这正是有助于迁移的重构。
将副作用与决策逻辑隔离
另一种有用的分离方式是:
deciding
与:
performing
假设当前取消订单的实现是这样的:
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);
}
这里存在两种不同的职责。
业务决策:
Can this order be cancelled?
What should its new state be?
以及执行层面的操作:
persist
release inventory
refund
audit
可以先提取出决策部分:
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;
}
行为保持不变,但状态转换现在可以独立测试和迁移。
如果目标架构改变了副作用的执行方式,这一点就很重要。
例如,未来版本可能会使用:
transactional outbox
event-driven workflow
queue
workflow engine
你不需要现在就引入这些机制。你只需要让当前的决策逻辑不再直接依赖它们。
用适配器隔离外部系统
外部 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<PaymentResult>;
}
Stripe 适配器负责封装供应商特有的细节:
class StripePaymentGateway implements PaymentGateway {
async charge(
request: PaymentRequest
): Promise<PaymentResult> {
const result =
await stripe.paymentIntents.create({
amount: request.amount,
currency: "usd",
metadata: {
orderId: request.orderId,
},
});
return {
paymentId: result.id,
};
}
}
应用现在只需要知道:
PaymentGateway
而不是:
Stripe SDK
这对迁移很有帮助,因为供应商相关代码被收敛到了局部。
同样的模式适用于:
email providers
message brokers
cloud storage
ERP integrations
CRM APIs
identity providers
search engines
适配器的价值不在于接口设计时髦,而在于它划出了一条可以移动的边界。
不推翻重来,改善依赖方向
遗留系统的依赖关系往往长这样:
business logic
↓
database SDK
↓
framework utilities
这让基础设施难以替换。
不一定非要全面贯彻 Clean Architecture,只需在迁移涉及的地方改善依赖方向即可。
例如:
改造前:
OrderService
↓
MySQL
改造后:
OrderService
↓
OrderRepository
↑
MySqlOrderRepository
应用依赖抽象,基础设施负责实现。
支付模块同理:
OrderService
↓
PaymentGateway
↑
StripePaymentGateway
以及消息发送:
OrderService
↓
OrderEvents
↑
KafkaOrderEvents
这样替换基础设施时,就不需要重写应用服务了。这正是关键成果。
提取内聚的应用边界
经过几次小型重构后,这项能力可能会演变成这样:
interface OrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}
interface PaymentGateway {
charge(request: {
orderId: string;
amount: number;
}): Promise<void>;
}
interface OrderEvents {
processed(order: Order): Promise<void>;
}
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;
}
}
这并不一定就是最终架构。这一点很重要。
我们并不是在说:
应用就应该永远保持这个样子。
我们想表达的只是:
这项能力现在有了清晰的边界,迁移起来更容易了。
基础设施可以独立变更。
业务规则可以测试,编排逻辑清晰可见,外部契约一目了然。
这就足够让我们开始考虑迁移了。
重构期间保持行为测试持续运行
上一步创建的特征测试(characterization tests)在这里就能派上用场了。
假设原始行为已经有如下测试保护:
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();
});
接下来可以这样改:
直接数据库访问
改为:
Repository
然后跑一次测试。
接着把:
直接调用支付 SDK
改为:
Payment Adapter
再跑一次测试。
然后提取:
定价逻辑
再跑一次测试。
节奏就变成了:
小的结构变更
↓
测试
↓
小的结构变更
↓
测试
↓
小的结构变更
↓
测试
这样做很重要,因为结构重构和行为变更不同时发生时,推理起来会容易得多。
一次小改动后测试挂了,可能的原因很有限。
两周大重写后测试挂了,几乎任何东西都可能是原因。
结构重构中如何使用 AI
这个阶段 AI 能帮上大忙。
但真正有用的 prompt 跟下面这种不一样:
用 Clean Architecture 重构这个应用。
而是给模型一个有约束的转换任务。
比如:
这个服务目前直接访问 MySQL。
我想引入一个 OrderRepository 接缝,
且不改变可观察的行为。
任务:
1. 找出该服务用到的所有数据库操作,
2. 提出所需的最小 Repository 接口,
3. 把现有数据库调用移到适配器后面,
4. 在相关处保留返回值、错误和调用顺序,
5. 不要改动业务规则,
6. 不要引入额外的抽象层。
生成代码之前,先解释每一个结构变更。
这样 AI 的任务就窄多了。
另一个有用的请求是:
对比重构前后的实现。
找出任何可能改变的可观察行为。
重点检查:
- 异常,
- 返回值,
- 副作用,
- 副作用的顺序,
- null 处理,
- 事务边界,
- 重试行为。
不要因为代码看起来相似就假设等价。
AI 在这里可以作为第二审查者发挥作用。
它能比手动逐行扫描更快地发现差异,但测试仍然是更强的证据。
别太早让 AI 设计目标架构
AI 很擅长识别常见的架构模式,但这也可能是危险的。
把一个大遗留服务丢给模型,问它:
这个服务该怎么现代化?
你可能会收到:
microservices
event-driven architecture
CQRS
repository pattern
domain events
message broker
API gateway
distributed cache
这些都是合理的技术或模式,但没有一个是天然就该用的。
选目标架构之前,你得先明确约束条件。
比如:
部署频率
团队规模
事务需求
延迟要求
容错能力
数据归属
集成边界
运维成熟度
流量
成本
合规要求
边界清晰的单体应用可能比 microservices 更适合作为目标。同步流程可能比事件驱动处理更合适。数据库迁移也不一定需要改动领域模型。
架构应该由约束驱动,而不是由模式识别驱动。
用 AI 来评估方案,别让「这个模式我见过」就变成采用的理由。
怎么判断一个能力可以迁移了
到某个阶段,你得停下来停止重构。这个决定很关键。
代码不必完美。当你能清楚回答以下问题时,一个能力就基本具备迁移条件了。
能描述它的输入吗?
例如:
orderId
customer
request payload
event
能描述它的输出吗?
例如:
已处理的订单
HTTP response
event
数据库变更
关键业务规则是否可见?
不一定要写得多完美,但你得知道它们在哪里。
外部依赖是否显式?
例如:
OrderRepository
PaymentGateway
OrderEvents
EmailSender
基础设施能否替换?
如果换成 MySQL 就得改定价逻辑,那边界大概率还没就绪。
关键行为是否有测试保护?
你应拥有足够的测试来捕获意外变更。
你知道副作用吗?
例如:
persist order
create payment
publish event
send email
主要的未知因素是否已记录在案?
总会存在一些不确定性,但不能让它隐身不见。
如果这些问题都能回答上来,说明结构已经足够清晰,可以开始迁移该能力了。
迁移前的实用重构流程
下面是我会采用的流程。
1. 选定一个能力
不要重构整个应用。
从这类功能中挑一个:
Process Order
Generate Invoice
Renew Subscription
2. 确保行为有测试保护
在动结构之前,先确认关键行为已有测试覆盖。
捕获的内容包括:
outputs
state transitions
side effects
errors
contracts
3. 找出迁移阻碍
留意这类耦合:
direct database access
provider SDKs
global state
framework-specific objects
static dependencies
shared mutable state
4. 尽可能抽出纯业务逻辑
把计算和决策逻辑从基础设施中剥离出来。
例如:
calculate price
validate transition
choose status
calculate commission
5. 引入接缝
在这些位置周围建立最小边界:
database
payments
events
email
storage
external APIs
没有迁移上的理由,就不要凭空造抽象。
6. 集中收拢基础设施
把与具体技术相关的行为移到 adapter 里。
例如:
MySqlOrderRepository
StripePaymentGateway
KafkaOrderEvents
SendGridEmailSender
7. 让编排流程清晰可见
目标是让一个能力的执行顺序一目了然:
load
↓
decide
↓
persist
↓
perform side effects
↓
return
8. 每完成一步就跑行为测试
别把十次重构攒在一起做,保持每次改动都很小。
9. 对比重构前后
检查:
inputs
outputs
errors
side effects
data shapes
ordering
transactions
10. 迁移条件具备时就停下
不要因为“代码还能更干净”就继续重构——它永远可以更干净。
目标是达到可迁移的状态。
迁移前不该重构的东西
这个阶段,除非某项改动直接阻碍迁移,否则我会避开以下几类操作。
全局重命名
几百个旧名字看着确实难受。但全部重命名只会产生大量 diff,对迁移本身几乎没有价值。
全仓库格式化
道理一样。格式噪音会让行为变更的 review 变得困难。
替换所有设计模式
老系统里可能塞满了:
singletons
service locators
static utilities
large classes
有些可以暂时保留。重点处理那些跨越迁移边界的。
重写稳定算法
如果某个老算法写得丑但被测试保护着、又足够隔离,先原样搬过去、以后再优化,往往更稳妥。
顺手修掉所有发现的 bug
这一条尤其关键。
迁移准备过程中发现 bug,先记录下来,再判断修不修该不该放在同一次提交里。
把下面三件事混在一起:
structural refactor
+
behavioral correction
+
platform migration
会让排查失败变得非常困难。
有时正确的做法是:
preserve bug
migrate
fix bug intentionally afterward
这听着让人不舒服。但迁移中无意间改变行为,风险往往更大。
重构是准备,不是迁移本身
迁移前的重构很容易变成一场没有尽头的架构改造。
你一开始说:
我们需要把数据库隔离出来。
接着:
应该重新设计领域模型。
然后:
也许该引入事件机制。
再然后:
既然做到这一步了,这块干脆拆成微服务吧。
几个月过去,什么都没迁完。重构本身变成了项目目标。
这也是一种失败模式。目标必须始终具体。
改造前:
ProcessOrder
├── MySQL
├── pricing rules
├── Stripe
├── Kafka
├── email
└── framework internals
改造后:
ProcessOrder
├── OrderRepository
├── pricing rules
├── PaymentGateway
├── OrderEvents
└── EmailSender
做到这一步就够了。
现在你有了选择空间。
你可以迁移:
MySQL → PostgreSQL
而不需要重新设计计价逻辑。
你可以替换:
Stripe adapter
而不改变订单编排逻辑。
你可以把:
ProcessOrder
迁移到另一个 runtime,同时保持其契约不变。
重构创造了选择空间,这就是它的价值所在。
总结
当多种类型的变更同时发生时,遗留系统的迁移风险会急剧上升。
你同时改动了:
behavior
architecture
infrastructure
runtime
data
deployment
然后还要去排查到底是哪项变更导致了故障。
更稳妥的做法是在迁移开始之前先消除这种不确定性。
先理解这项能力,再刻画它的行为,然后在不改变其功能的前提下调整内部结构。
围绕依赖划定边界,把业务决策和基础设施分开,将外部系统本地化,让副作用保持可观测,每次结构变更后跑行为测试,等能力变得可移动时停止重构。
流程就变成了:
Understand
↓
Characterize
↓
Refactor
↓
Migrate
AI 可以大幅加速重构阶段。
它能识别依赖、提取接口、把调用挪到 adapter 后面、对比不同实现、审查大段 diff。
但重构更快并不意味着不需要架构判断了,反而让它更重要。
因为目标不是把遗留系统改得最干净,而是搭建刚好够用的结构,让它能被安全地迁移。
当你改基础设施不再影响行为时,迁移就不再像是一次重写。
而变成一系列可控的变更。