来聊聊我为什么放弃了三层架构

0 阅读8分钟

写在前面:这篇文章不教你怎么画架构图,也不教你怎么背八股。而是分享一个真实的故事——我是怎么从「增删改查」走到「领域驱动设计」的,以及为什么在经历了三次大促翻车后,决定彻底重构自己的架构思维。


🍔 故事开场:一个让所有人睡不着觉的夜晚

那是某一次的双11,我负责的电商项目第一次参与大促。

晚上10点,流量开始起飞。我盯着监控面板,看着QPS从500一路飙升到5000,心里默念「顶住顶住顶住」。

然后,崩了。

订单突然卡住,用户点击下单后转圈转了几十秒,最后提示「系统繁忙,稍后重试」

我赶紧上服务器查日志,各种超时异常扑面而来。业务那边电话一个接一个,用户投诉铺天盖地。

那天晚上,我们熬到凌晨4点才把系统救回来。后来复盘,发现问题出在一个看似毫不起眼的地方——

订单模块和库存模块,写在了同一个Service里。

@Service
public class OrderService {
    
    @Autowired private OrderDao orderDao;
    @Autowired private StockDao stockDao;
    @Autowired private ProductDao productDao;
    @Autowired private UserDao userDao;
    // ... 下面还有20个dao
    
    public void createOrder(OrderDTO dto) {
        // 查库存
        Stock stock = stockDao.findByProductId(dto.getProductId());
        if (stock.getCount() < dto.getQuantity()) {
            throw new RuntimeException("库存不足");
        }
        // 扣库存
        stock.setCount(stock.getCount() - dto.getQuantity());
        stockDao.update(stock);
        
        // 创建订单
        Order order = new Order();
        order.setProductId(dto.getProductId());
        order.setQuantity(dto.getQuantity());
        // ... 复制20个字段
        orderDao.insert(order);
        
        // 扣用户余额
        // 发货通知
        // 记录日志
        // ... 又是一堆逻辑
    }
}

你发现问题了吗?

一个方法里,干了太多件事。查库存、扣库存、创订单、扣余额、发通知、记日志...全挤在一起。

这就导致:

  1. 代码难以维护 — 改库存逻辑要拽整个订单方法
  2. 性能无法优化 — 库存扣减和订单创建绑死,无法异步
  3. 测试根本无法写 — 20个依赖,谁敢mock?

那晚之后,我开始问自己:「是不是我写的代码,从根子上就是错的?」


🔧 核心干货:什么是DDD?先扔掉那些晦涩的定义

网上关于DDD的定义一搜一大把,什么「领域模型」「限界上下文」「聚合根」...看完更懵了。

我换个说法。

DDD的本质:像划分城市一样划分系统。

想象一下你要设计一座城市。你会让所有功能——住宅、商场、医院、学校、工厂——都混在一起吗?

当然不会。

你会把城市分成若干个区域:商业区、住宅区、工业区、政务区。每个区域有自己的职责,区域之间通过道路和桥梁连接。

DDD做的事情一模一样:

城市概念DDD术语我的理解
城市分区限界上下文(Bounded Context)一个独立的业务领域,有自己的边界
区域职责领域(Domain)这个区域「做什么」——比如订单域、库存域
区域间的路防腐层(ACL)两个区域对话的「翻译官」,各自说自己的话
区域负责人聚合根(Aggregate Root)一个领域的老大,统一管理内部事务

用人话说:DDD就是教你怎么把一个庞大的系统,像切披萨一样切成若干块,每块「各管一摊事」,块与块之间通过标准接口沟通。


💡 线上翻车实录:三层架构是怎么坑我的

在说DDD之前,先说说我们原来的架构——贫血三层架构

这是大部分Java程序员最熟悉的模式:

Controller → Service → DAO

它的特点是:所有的「业务逻辑」都写在Service里,Model只是一个个「数据容器」,只有属性,没有行为。

// 贫血模型——只有属性,没有行为
@Data
public class Order {
    private Long id;
    private Long productId;
    private Integer quantity;
    private BigDecimal price;
    // ... 20个字段
}

// 所有的业务逻辑都在Service里
@Service
public class OrderService {
    public void createOrder(OrderDTO dto) {
        // 业务逻辑全在这里
        // 校验参数
        // 查库存
        // 扣库存
        // 创建订单
        // 发通知
        // 记录日志
    }
}

这种方法有什么问题?

问题1:业务知识泄露

想象一下,「订单创建时必须校验库存」这个规则,应该放在哪里?

在贫血模型里,你只能放在Service里。于是你会看到:

public void createOrder(...) {
    // 规则1:校验库存
    Stock stock = stockDao.findByProductId(productId);
    if (stock.getCount() < quantity) {
        throw new Exception("库存不足");
    }
    // ... 后面还有30个类似规则
}

public void cancelOrder(...) {
    // 规则2:取消订单要回滚库存
    Stock stock = stockDao.findByProductId(productId);
    stock.setCount(stock.getCount() + quantity);
    stockDao.update(stock);
    // ... 
}

「校验库存」「回滚库存」这些业务规则,散落在Service的各个方法里。

改的时候你要全文搜索「库存」关键字,漏掉一个可能就是线上bug。

问题2:Service层越来越胖

每次新增需求,都在Service里加方法。三个月后,一个Service有2000行代码,20个依赖。

不敢改、不敢测、不敢接新需求。

问题3:无法应对高并发

还是那个双11的case。

订单和库存绑死在同一个事务里,库存扣减必须等订单创建完成才能释放数据库连接。流量一高,连接池瞬间耗尽。

这就是为什么我们崩了。


⚡ 架构重构:我是怎么用DDD拆系统的

痛定思痛,我开始用DDD的思路重构系统。

第一步:划分限界上下文

我把整个电商系统拆成了几个「域」:

限界上下文职责包含的核心概念
订单域处理订单相关业务订单、订单项、售后单
库存域管理商品库存库存、库存流水
支付域处理支付流水支付单、支付流水
用户域用户账户管理用户、地址、积分

每个域独立开发、独立部署,域与域通过防腐层通信。

第二步:充血模型改造

原来散落在Service里的业务逻辑,现在收进对应的领域模型里:

// 充血模型——订单自己知道怎么被创建
public class Order {
    private Long id;
    private List<OrderItem> items;
    private OrderStatus status;
    
    // 订单的「行为」,业务逻辑封装在模型内部
    public void create(List<Product> products) {
        // 校验库存
        for (Item item : items) {
            Product product = findProduct(item.getProductId());
            if (!product.hasEnoughStock(item.getQuantity())) {
                throw new BusinessException("库存不足");
            }
        }
        
        // 扣减库存(委托给库存域)
        for (Item item : items) {
            product.reduceStock(item.getQuantity());
        }
        
        // 设置订单状态
        this.status = OrderStatus.CREATED;
    }
    
    public void cancel() {
        if (this.status != OrderStatus.CREATED) {
            throw new BusinessException("订单已发货,无法取消");
        }
        // 回滚库存
        // ...
    }
}

Service变薄了:从2000行变成200行,只负责「编排」——调用领域模型的方法,把领域模型存入仓库。

@Service
public class OrderApplicationService {
    
    @Autowired private OrderRepository orderRepository;
    @Autowired private StockService stockService;  // 防腐层
    
    public void createOrder(CreateOrderCommand cmd) {
        // 1. 构造领域模型
        Order order = new Order();
        order.create(cmd.getItems());
        
        // 2. 保存
        orderRepository.save(order);
        
        // 3. 异步发消息(不阻塞主流程)
        eventPublisher.publish(new OrderCreatedEvent(order));
    }
}

第三步:聚合根设计

每个域内部,用聚合根来统一管理实体。

以订单域为例:

Order(聚合根) 
  ├── OrderItem(实体)
  └── OrderLog(值对象)

聚合根的职责:所有对OrderItem的修改,必须通过Order发起,外部不能直接操作OrderItem。

public class Order {
    private List<OrderItem> items;
    
    // 只有Order自己能添加订单项
    public void addItem(Product product, int quantity) {
        // 业务校验逻辑
        if (quantity <= 0) throw new Exception("数量必须大于0");
        
        // 创建订单项
        OrderItem item = new OrderItem(product, quantity);
        this.items.add(item);
    }
    
    // 外部无法直接操作items,只能调用order的方法
}

好处是什么? 业务规则被严格封装在这个「小王国」里,不会出现「某个地方漏写了校验」的情况。


💯 面试必背:DDD核心概念表

概念一句话解释面试加分
限界上下文领域的边界,不同上下文用不同语言「我们按业务划分了X个限界上下文」
聚合根一个域的老大,统一管理内部实体「订单是聚合根,订单项受订单管理」
充血模型模型自带业务逻辑,不只是数据容器「业务规则封装在领域模型里」
防腐层两个域之间的「翻译官」「库存域和订单域通过ACL通信」
领域事件领域内发生的大事,触发后续动作「订单创建后发布OrderCreatedEvent」
贫血模型只有getter/setter,没有业务方法「传统三层架构的痛点」

🛠️ 实际开发建议:DDD怎么落地?

不要一开始就追求完美。

我的建议是:

  1. 先从「 Service太胖」的地方开始

    • 找一个2000行的Service,尝试把业务逻辑移到一个Domain Model里
  2. 从一个聚合根试点

    • 选一个核心业务(比如订单),严格按照「聚合根管一切」的方式重构
  3. 防腐层先用最简单的形式

    • 两个域之间,先用HTTP接口或消息队列通信,不着急上复杂的协议
  4. 领域事件可以先用Spring Event

    • 简单,不用额外搭基础设施

DDD不是银弹,它的目的是让复杂业务可维护。 如果你的项目只是简单的CRUD,别为了DDD而DDD。


📌 总结:记住这口诀

业务先行再架构,
域内充血域外薄。
聚合根管一切,
防腐层来把话接。

说人话:先想清楚业务怎么划分,领域内部用充血模型把逻辑封装好,聚合根统一管理,域之间通过防腐层标准化通信。


🤔 思考题

你现在的项目里,有哪个模块的Service已经「胖」到改不动了?如果用DDD思路,你会怎么拆?

评论区聊聊。