软件架构模式
架构设计的分层与取舍指南:从分层架构、六边形架构讲到 DDD 的核心概念与适用边界,分析单体与微服务的真实成本,给出模块化单体的演进路径,并附常用设计模式的实战写法、依赖方向原则与架构决策记录方法。
一、架构解决的是什么问题
架构不是「用什么框架」,而是在需求持续变化的前提下,控制改动的传播范围。
| 没有架构 | 有合理架构 |
|---|---|
| 改一个功能要动十几个文件 | 改动集中在一层/一个模块 |
| 业务逻辑散落在 Controller 里 | 业务逻辑集中在领域层 |
| 换数据库要重写半个项目 | 只换数据访问实现 |
| 无法单独测试业务逻辑 | 业务逻辑可脱离框架测试 |
判断架构好坏的一条实用标准:
当需求变化时,你能不能只改一处?改一处之后,是否不影响其他部分?
二、分层架构
2.1 经典四层
Controller(接口层) 处理 HTTP/参数校验/响应组装 ↓Service(应用层) 编排业务流程、事务边界 ↓Domain(领域层) 核心业务规则、不依赖框架 ↓Repository(数据访问层) 数据库读写、外部服务调用2.2 各层职责与禁止事项
| 层 | 该做 | 不该做 |
|---|---|---|
| Controller | 参数校验、调用 Service、组装响应 | 写业务判断、直接操作数据库 |
| Service | 编排流程、控制事务、组合领域对象 | 处理 HTTP 细节(header/cookie) |
| Domain | 业务规则、不变量校验 | 依赖框架、直接查数据库 |
| Repository | 数据读写、查询优化 | 业务判断 |
2.3 代码示例
// Controller:只做接口适配router.post('/orders', async (ctx) => { const dto = validateCreateOrder(ctx.request.body) // 参数校验 const order = await orderService.create(dto, ctx.state.user.id) ctx.body = { code: 0, data: { orderNo: order.orderNo } }})// Service:编排流程 + 事务class OrderService { async create(dto, userId) { const product = await productRepo.findById(dto.productId) if (!product) throw new BusinessError('商品不存在') const order = Order.create({ userId, product, quantity: dto.quantity }) // 领域逻辑在实体里 await transaction(async (trx) => { await stockRepo.decrease(product.id, dto.quantity, trx) // 条件更新防超卖 await orderRepo.save(order, trx) }) await eventBus.emit('order.created', { orderNo: order.orderNo }) // 后续动作异步 return order }}// Domain:业务规则内聚在实体中,可独立单测class Order { static create({ userId, product, quantity }) { if (quantity <= 0) throw new BusinessError('数量必须大于 0') if (quantity > 99) throw new BusinessError('单次最多购买 99 件') return new Order({ orderNo: generateOrderNo(), userId, productId: product.id, quantity, unitPrice: product.price, // 价格以服务端商品数据为准 totalAmount: product.price * quantity, status: 'pending', }) } markPaid(transactionId) { if (this.status !== 'pending') throw new BusinessError('订单状态不允许支付') this.status = 'paid' this.paidAt = new Date() this.transactionId = transactionId }}关键收益:Order 的业务规则不依赖数据库、框架,可以纯单元测试。
2.4 分层的常见误用
| 误用 | 后果 |
|---|---|
Controller 里写满 if/else 业务判断 |
逻辑无法复用、无法测试 |
| Service 直接返回数据库实体 | 上层耦合数据结构,字段变更影响面大 |
| Domain 层引入 ORM 注解 | 领域对象无法脱离框架测试 |
| 层与层之间相互调用(Controller 直接调 Repository) | 绕过业务规则,产生不一致 |
三、六边形架构(端口与适配器)
3.1 核心理念
业务核心在中心,外部世界通过「端口」接入,由「适配器」实现具体技术。
┌─────────────┐ HTTP ──┤ Controller │──┐ └─────────────┘ │ ┌─────────────┐ │ ┌──────────────────┐ CLI ──┤ Command │──┼─────►│ 领域核心 │ └─────────────┘ │ │ (业务规则) │ ┌─────────────┐ │ └──────────────────┘ MQ ──┤ Consumer │──┘ │ └─────────────┘ │ ┌───────┴────────┐ │ 端口(接口) │ └───────┬────────┘ ┌──────────────┼──────────────┐ ┌────▼────┐ ┌─────▼─────┐ ┌─────▼─────┐ │ MySQL │ │ Redis │ │ 第三方 API │ │ 适配器 │ │ 适配器 │ │ 适配器 │ └─────────┘ └───────────┘ └───────────┘3.2 端口与适配器
// 端口:领域层定义的接口(不关心实现)// domain/ports/OrderRepository.jsclass OrderRepository { async findById(id) { throw new Error('not implemented') } async save(order) { throw new Error('not implemented') }}// 适配器:基础设施层实现// infrastructure/persistence/MysqlOrderRepository.jsclass MysqlOrderRepository extends OrderRepository { async findById(id) { const row = await db.query('SELECT * FROM orders WHERE id = ?', [id]) return row ? toOrderEntity(row) : null } async save(order) { await db.query('INSERT INTO orders ... ON DUPLICATE KEY UPDATE ...', [...]) }}// 测试用内存适配器:无需数据库即可测业务class InMemoryOrderRepository extends OrderRepository { constructor() { super(); this.store = new Map() } async findById(id) { return this.store.get(id) || null } async save(order) { this.store.set(order.id, order) }}最大收益:业务逻辑的测试不再需要数据库;换存储只需新写一个适配器。
3.3 何时值得引入
| 情况 | 建议 |
|---|---|
| 业务规则复杂、需要大量单测 | ✅ 值得 |
| 需要支持多种存储/多种接入方式 | ✅ 值得 |
| 简单 CRUD 后台 | ❌ 过度设计,分层架构足够 |
四、DDD 入门
4.1 核心概念
| 概念 | 含义 | 示例 |
|---|---|---|
| 限界上下文 | 业务边界,同一术语在不同上下文含义不同 | 「商品」在销售上下文有价格,在仓储上下文有库位 |
| 实体(Entity) | 有唯一标识、生命周期内可变 | 订单、用户 |
| 值对象(Value Object) | 无标识、不可变、按值比较 | 金额、地址、时间区间 |
| 聚合(Aggregate) | 一组相关对象的一致性边界 | 订单 + 订单项 |
| 聚合根 | 聚合的唯一入口,外部只能通过它访问 | 订单是订单项的聚合根 |
| 领域事件 | 领域中发生的事实 | 订单已支付、库存已扣减 |
| 仓储(Repository) | 聚合的持久化抽象 | OrderRepository |
4.2 值对象示例
// ✅ 值对象:把规则内聚,避免散落各处的判断class Money { constructor(amountInCents, currency = 'CNY') { if (!Number.isInteger(amountInCents)) throw new Error('金额必须为整数分') if (amountInCents < 0) throw new Error('金额不能为负') this.amount = amountInCents this.currency = currency Object.freeze(this) } add(other) { if (other.currency !== this.currency) throw new Error('币种不一致') return new Money(this.amount + other.amount, this.currency) } multiply(n) { return new Money(Math.round(this.amount * n), this.currency) } toString() { return (this.amount / 100).toFixed(2) }}收益:金额的「非负、整数、币种一致」规则只写一次,不会在每个 Service 里重复判断。
4.3 聚合的边界设计
原则:
- 一个事务只修改一个聚合——跨聚合用领域事件最终一致。
- 聚合宜小不宜大——聚合越大,并发冲突越多、加载越慢。
- 外部通过聚合根访问内部对象,不直接操作子对象。
// ✅ 订单项只通过订单修改class Order { addItem(product, quantity) { if (this.status !== 'pending') throw new Error('已提交的订单不能修改') this.items.push(new OrderItem(product, quantity)) this.recalculateTotal() }}// ❌ 绕过聚合根直接改子对象,规则被跳过order.items.push(new OrderItem(...))4.4 何时用 DDD、何时不用
| 适合 | 不适合 |
|---|---|
| 业务规则复杂、术语多、多方角色 | 纯 CRUD 数据维护 |
| 长期迭代、规则频繁变化的核心域 | 简单表单、报表查询 |
| 团队需要统一语言沟通 | 一次性项目、原型验证 |
实用建议:不必全项目套 DDD。只对「核心域」用(如交易、计价、风控),其余部分用简单分层即可。强行给 CRUD 上聚合根,是最常见的过度设计。
五、单体 vs 微服务
5.1 客观对比
| 维度 | 模块化单体 | 微服务 |
|---|---|---|
| 部署 | 一个进程 | N 个服务独立部署 |
| 开发效率(初期) | ✅ 高 | ❌ 低(需处理服务间通信) |
| 事务一致性 | ✅ 简单(本地事务) | ❌ 复杂(分布式事务/Saga) |
| 故障隔离 | ❌ 一处崩溃全站崩 | ✅ 单服务故障可降级 |
| 独立扩容 | ❌ 只能整体扩 | ✅ 热点服务单独扩 |
| 技术异构 | ❌ 单一技术栈 | ✅ 各服务自选 |
| 运维复杂度 | ✅ 低 | ❌ 高(注册发现、链路追踪、配置中心) |
| 团队规模要求 | 小团队更合适 | 需要独立团队负责各服务 |
5.2 微服务的真实成本
| 成本项 | 说明 |
|---|---|
| 分布式事务 | 跨服务数据一致性需要 Saga/TCC/最终一致,复杂度陡增 |
| 网络故障 | 调用可能超时、重试、幂等,处处要考虑 |
| 调试困难 | 一次请求跨多个服务,必须上链路追踪 |
| 数据一致性 | 无法使用数据库外键与联表查询 |
| 运维负担 | 服务注册、配置管理、网关、日志聚合、监控告警 |
| 组织要求 | 康威定律:服务边界往往映射团队边界,团队不够就是负担 |
5.3 判断标准
不要上微服务,如果:
- 团队规模小于 10 人
- 单体部署时间 < 5 分钟、故障影响可接受
- 没有独立的运维与监控能力
- 业务边界还不清晰(此时拆分会拆错)
可以考虑微服务,如果:
- 单体已大到「改动互相阻塞、发布排队」
- 存在明显不同伸缩需求的模块(如报表服务吃 CPU、网关吃连接)
- 有多个团队需要独立发布节奏
- 已具备完善的 CI/CD、监控、链路追踪基础设施
5.4 推荐路径:模块化单体优先
阶段一:分层单体 └─ Controller / Service / Repository,按技术分层阶段二:模块化单体(多数项目的最优解) └─ 按业务模块切分,模块内分层,模块间只通过明确接口通信阶段三:仅在必要时抽取独立服务 └─ 某个模块需要独立扩容或有独立发布节奏 → 拆出去模块化单体的目录结构:
src/├── modules/│ ├── order/│ │ ├── controller.js│ │ ├── service.js│ │ ├── domain/│ │ ├── repository.js│ │ └── index.js ← 唯一对外出口│ ├── product/│ └── user/├── shared/ ← 共享工具、通用组件└── app.js约束:模块之间只能通过 index.js 暴露的接口调用,不允许直接引用对方内部文件。
// modules/order/service.jsconst productModule = require('../product') // ✅ 通过模块出口// const productRepo = require('../product/repository') // ❌ 禁止跨模块直接访问内部class OrderService { async create(dto) { const product = await productModule.getForSale(dto.productId) // 走公开接口 }}收益:模块间依赖清晰,将来拆微服务时边界已经现成。
六、常用设计模式实战
设计模式的价值不在「用得多」,而在命名一个反复出现的问题的解法。
6.1 策略模式(替换大量 if/else)
// ❌ 分支爆炸function calcFreight(type, weight) { if (type === 'standard') return weight * 2 else if (type === 'express') return weight * 5 + 10 else if (type === 'free') return 0 // 每加一种就要改这个函数}// ✅ 策略表const FREIGHT_STRATEGIES = { standard: (w) => w * 2, express: (w) => w * 5 + 10, free: () => 0,}function calcFreight(type, weight) { const strategy = FREIGHT_STRATEGIES[type] if (!strategy) throw new Error('未知运费类型') return strategy(weight)}识别信号:同一个 switch/if-else 因为新需求被反复修改。
6.2 工厂模式(统一构建复杂对象)
const PAYMENT_PROVIDERS = { alipay: () => new AlipayProvider(cfg.alipay), wechat: () => new WechatProvider(cfg.wechat), balance: () => new BalanceProvider(),}function createPaymentProvider(type) { const factory = PAYMENT_PROVIDERS[type] if (!factory) throw new Error('不支持的支付方式') return factory()}6.3 装饰器模式(横切关注点)
// 限流 + 缓存 + 日志,用装饰器叠加,不污染业务代码const getProducts = withCache(60)(withRateLimit(100)(withLog(baseGetProducts)))6.4 观察者 / 事件模式(解耦后续动作)
// 下单后要发短信、加积分、写报表 —— 用事件解耦,而不是在 Service 里逐个调用eventBus.on('order.created', sendSmsHandler)eventBus.on('order.created', addPointsHandler)eventBus.on('order.created', writeReportHandler)注意:事件是异步的,要接受最终一致;关键流程(如扣库存)不能只靠事件。
6.5 模式使用原则
| 原则 | 说明 |
|---|---|
| 先有问题,再找模式 | 为用模式而用模式是最常见的浪费 |
| 模式要能命名 | 团队能说「这里是策略模式」才算达成共识 |
| 简单优先 | 三行能解决的不要引入工厂 + 接口 + 注册表 |
七、依赖方向原则
7.1 依赖倒置
❌ 业务层依赖具体实现Domain ──► MySQL ──► 具体驱动✅ 业务层依赖抽象,实现反向注入Domain ──► 接口(端口)◄── MySQL 适配器// ✅ 依赖注入:业务层接收抽象的仓储class OrderService { constructor(orderRepo) { this.orderRepo = orderRepo } // 只依赖接口}// 生产环境const service = new OrderService(new MysqlOrderRepository())// 测试环境const service = new OrderService(new InMemoryOrderRepository())7.2 三条硬规则
| 规则 | 说明 |
|---|---|
| 内层不依赖外层 | 领域层不能 import 框架、ORM、HTTP 库 |
| 依赖抽象而非实现 | Service 依赖 OrderRepository 接口,不依赖 MysqlOrderRepository |
| 跨模块只走公开接口 | 不直接访问其他模块的内部文件 |
八、架构决策记录(ADR)
8.1 为什么需要
半年后没人记得「当初为什么不用 X 方案」。重新讨论一次的成本极高,还容易推翻已有合理设计。
8.2 简化模板(一页纸)
# ADR-003:订单模块采用模块内分层,暂不拆微服务日期:2026-01-01状态:已采纳## 背景订单模块业务规则复杂,但团队仅 5 人,且部署时间 < 3 分钟。## 决策采用模块化单体:订单作为独立模块,模块内分层(Controller/Service/Domain/Repository),模块间只通过 index.js 公开接口通信;暂不拆分独立服务。## 备选方案- 直接拆微服务:需要引入注册中心、链路追踪,运维成本超出团队承受范围。- 纯技术分层:模块边界不清晰,订单与商品逻辑相互渗透。## 后果- 正面:开发效率高、本地事务简单、部署简单。- 负面:订单高峰期无法单独扩容。- 触发重新评估的条件:订单模块 QPS 超过单机承载的 60%,或团队规模超过 15 人。8.3 什么决策值得记录
| 值得记 | 不必记 |
|---|---|
| 技术选型(框架、数据库、中间件) | 变量命名 |
| 架构分层与模块边界 | 代码格式 |
| 关键取舍(一致性 vs 可用性) | 显而易见的实现细节 |
| 暂不做什么及原因 | — |
九、常见误区
| 误区 | 事实 |
|---|---|
| 「先设计完美架构再开发」 | 业务边界要靠实践验证,过度设计会阻碍调整 |
| 「小项目不需要分层」 | 小项目更需要简单结构,否则三个月后就是泥球 |
| 「微服务更先进」 | 微服务是组织与规模的解法,不是技术先进的证明 |
| 「DDD 就是分层 + 一堆名词」 | 没有统一语言与业务边界,DDD 只剩复杂度 |
| 「设计模式用得越多越好」 | 模式是给重复问题的命名,不是目标 |
| 「分层就是多写几个文件」 | 分层的价值在依赖方向,不是文件数量 |
十、检查清单
分层
- Controller 无业务判断,只做参数校验与响应组装
- 业务规则内聚在 Service/Domain,不在 Controller
- 上层不直接访问 Repository 绕过业务规则
- 领域逻辑可脱离框架与数据库单测
模块边界
- 按业务模块(而非技术类型)组织目录
- 模块间只通过公开接口(
index.js)通信 - 模块内部分层,外部不可见
依赖
- 内层不依赖外层框架
- 依赖抽象(接口)而非具体实现
- 关键依赖通过构造注入,便于测试替换
选型
- 单体/微服务选择基于团队规模与真实痛点,而非跟风
- 若需要拆分,先做到模块化单体
- 已评估微服务的隐藏成本(分布式事务、链路追踪、运维)
文档
- 关键架构决策有 ADR(背景、决策、备选、后果)
- ADR 写明了「何时重新评估」的触发条件
十一、总结
架构的核心只有三句话:
- 分层的价值在依赖方向——内层不依赖外层,业务逻辑能脱离框架测试。文件分几个目录不重要,依赖指向才重要。
- 模块边界的价值在改动收敛——按业务模块切分、只走公开接口,让「改一个需求只动一个模块」成为可能。这也是将来拆服务的现成边界。
- 微服务是组织规模的解法,不是技术先进的标志——团队小、边界不清时拆微服务,只会把复杂度乘以服务数。
落地顺序建议:
先分层(Controller/Service/Domain/Repository) ↓再模块化(按业务切分,模块间只走公开接口) ↓核心域引入 DDD(值对象、聚合、领域事件) ↓确有瓶颈才拆服务最后一条实践建议:把架构规则写进 lint 或 CI(如禁止跨模块直接引用、禁止 Controller 引用 Repository)。只写在文档里的架构约束,通常半年后就会被绕过。
完