AI应用的领域驱动设计:DDD在AI系统中的实践
AI应用的领域驱动设计:DDD在AI系统中的实践
一、开篇故事:大泥球的诞生
2025年11月,某在线教育平台的研发负责人老周在做代码Review时,看到了一个让他沉默3分钟的Service类。
@Service
public class AiTutorService {
@Autowired
private ChatClient chatClient;
@Autowired
private UserRepository userRepository;
@Autowired
private CourseRepository courseRepository;
@Autowired
private QuestionRepository questionRepository;
@Autowired
private AnswerRepository answerRepository;
@Autowired
private KnowledgeBaseRepository knowledgeBaseRepository;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private RabbitTemplate rabbitTemplate;
@Autowired
private EmailService emailService;
@Autowired
private SmsService smsService;
// 方法1:学生提问(230行)
public AiTutorResponse askQuestion(Long userId, Long courseId, String question) {
// 验证用户
User user = userRepository.findById(userId).orElseThrow();
if (!user.isActive()) throw new RuntimeException("用户未激活");
// 验证课程
Course course = courseRepository.findById(courseId).orElseThrow();
if (!course.isPublished()) throw new RuntimeException("课程未发布");
// 检查用户是否已购买课程
if (!course.getStudents().contains(userId)) {
throw new RuntimeException("未购买此课程");
}
// 限流检查(直接查Redis)
String limitKey = "ai:limit:" + userId;
Long count = redisTemplate.opsForValue().increment(limitKey);
if (count == 1) redisTemplate.expire(limitKey, 1, TimeUnit.HOURS);
if (count > 10) throw new RuntimeException("提问次数超限");
// 查找相关知识点
List<KnowledgePoint> points = knowledgeBaseRepository
.findByCourseIdAndRelevant(courseId, question);
String context = points.stream()
.map(KnowledgePoint::getContent)
.collect(Collectors.joining("\n"));
// AI回答
String answer = chatClient.prompt()
.system("你是一位" + course.getSubject() + "老师")
.user(question + "\n参考材料:" + context)
.call()
.content();
// 保存记录
QaRecord record = new QaRecord();
record.setUserId(userId);
record.setCourseId(courseId);
record.setQuestion(question);
record.setAnswer(answer);
record.setCreatedAt(LocalDateTime.now());
questionRepository.save(record);
// 更新用户学习统计
user.setAiQuestionCount(user.getAiQuestionCount() + 1);
user.setLastActiveAt(LocalDateTime.now());
userRepository.save(user);
// 发MQ通知推荐系统
rabbitTemplate.convertAndSend("ai.qa.completed", record);
// 检查是否达到里程碑
if (user.getAiQuestionCount() % 100 == 0) {
emailService.sendMilestoneEmail(user.getEmail(), user.getAiQuestionCount());
}
return new AiTutorResponse(answer, record.getId());
}
// 方法2、3、4... 每个方法都是类似的大杂烩
}这个Service类已经有2800行,包含了:
- 业务规则验证(用户状态、课程状态、购买检查)
- 基础设施操作(Redis限流、JPA读写)
- AI调用逻辑
- 外部系统集成(MQ、邮件、短信)
- 统计更新
老周找到老张,老张说:"你的代码是大泥球(Big Ball of Mud),你需要DDD来重新梳理边界。AI调用应该在哪一层?聚合根是什么?业务规则应该在哪里?"
这篇文章,我们用DDD重构这个AI应用。
二、DDD核心概念在AI系统中的映射
2.1 概念对应关系
2.2 AI系统的核心域和支撑域划分
核心域(Core Domain):
- AI教学对话:差异化竞争力,直接影响用户价值
- 个性化推荐:根据学习行为推荐学习路径
支撑域(Supporting Domain):
- 知识库管理:支撑核心域,有一定业务逻辑
- 学习统计:数据收集,辅助核心域
通用域(Generic Domain):
- 用户认证:通用功能,可外购SaaS
- 文件存储:通用,用OSS即可
- 消息通知:通用,用第三方服务三、完整项目结构与依赖
3.1 pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
</parent>
<groupId>com.laozhang.ai</groupId>
<artifactId>ai-ddd-tutor</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
<spring-ai.version>1.0.0-M6</spring-ai.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring AI -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
</dependency>
<!-- 持久化 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 消息队列 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<repositories>
<repository>
<id>spring-milestones</id>
<url>https://repo.spring.io/milestone</url>
</repository>
</repositories>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>3.2 application.yml
server:
port: 8080
spring:
application:
name: ai-ddd-tutor
ai:
openai:
api-key: ${DASHSCOPE_API_KEY}
base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
chat:
options:
model: qwen-plus
temperature: 0.3 # 教学场景要求准确性,降低随机性
datasource:
url: jdbc:mysql://localhost:3306/ai_tutor?useUnicode=true&characterEncoding=utf8
username: ${DB_USER:root}
password: ${DB_PASSWORD:password}
jpa:
hibernate:
ddl-auto: update
show-sql: false
data:
redis:
host: localhost
port: 6379
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
# DDD分层日志
logging:
level:
com.laozhang.ai.domain: DEBUG
com.laozhang.ai.application: INFO
com.laozhang.ai.infrastructure: WARN3.3 DDD分层目录结构
src/main/java/com/laozhang/ai/
├── domain/ # 领域层(最核心,无外部依赖)
│ ├── conversation/ # 对话聚合
│ │ ├── Conversation.java # 聚合根
│ │ ├── Message.java # 实体
│ │ ├── ConversationId.java # 值对象
│ │ ├── MessageContent.java # 值对象
│ │ ├── ConversationRepository.java # 仓储接口
│ │ └── event/
│ │ ├── QuestionAskedEvent.java
│ │ └── AnswerReceivedEvent.java
│ ├── knowledge/ # 知识库聚合
│ │ ├── KnowledgeBase.java
│ │ ├── KnowledgePoint.java
│ │ └── KnowledgeBaseRepository.java
│ └── shared/ # 共享值对象
│ ├── TokenUsage.java
│ ├── AiResponse.java
│ └── UserId.java
├── application/ # 应用层(编排领域逻辑)
│ ├── command/
│ │ ├── AskQuestionCommand.java
│ │ └── CreateConversationCommand.java
│ ├── query/
│ │ └── GetConversationHistoryQuery.java
│ ├── service/
│ │ ├── AskQuestionApplicationService.java
│ │ └── KnowledgeBaseApplicationService.java
│ └── dto/
│ └── ConversationDTO.java
├── infrastructure/ # 基础设施层
│ ├── persistence/
│ │ ├── ConversationJpaRepository.java
│ │ ├── ConversationRepositoryImpl.java
│ │ └── po/ConversationPO.java
│ ├── ai/
│ │ └── SpringAiAdapter.java # AI调用适配器
│ └── messaging/
│ └── RabbitMqEventPublisher.java
└── interfaces/ # 接口层
├── rest/
│ └── ConversationController.java
└── dto/
└── AskQuestionRequest.java四、聚合根设计:Conversation、KnowledgeBase作为聚合根
4.1 Conversation聚合根
package com.laozhang.ai.domain.conversation;
import com.laozhang.ai.domain.shared.*;
import lombok.*;
import java.time.Instant;
import java.util.*;
/**
* 对话聚合根
*
* 聚合根职责:
* 1. 维护对话的完整性(消息列表、状态)
* 2. 封装所有业务规则(如消息数量限制、对话状态转换)
* 3. 产生领域事件
*
* 注意:聚合根不依赖任何Spring组件、不直接调用AI模型
*/
@Getter
public class Conversation {
// ====== 聚合标识 ======
private final ConversationId id;
private final UserId userId;
private final String courseId;
// ====== 业务状态 ======
private ConversationStatus status;
private final List<Message> messages;
private int totalInputTokens;
private int totalOutputTokens;
private final Instant createdAt;
private Instant updatedAt;
// ====== 业务规则常量 ======
private static final int MAX_MESSAGES_PER_CONVERSATION = 100;
private static final int MAX_DAILY_QUESTIONS = 20;
// ====== 待发布的领域事件 ======
private final List<Object> domainEvents = new ArrayList<>();
/**
* 创建新对话(工厂方法)
*/
public static Conversation create(UserId userId, String courseId) {
Objects.requireNonNull(userId, "userId不能为空");
Objects.requireNonNull(courseId, "courseId不能为空");
Conversation conversation = new Conversation(
ConversationId.generate(),
userId,
courseId
);
// 发布领域事件
conversation.addDomainEvent(new ConversationCreatedEvent(
conversation.getId(), userId, courseId, Instant.now()
));
return conversation;
}
/**
* 构造函数:私有,强制通过工厂方法创建
*/
private Conversation(ConversationId id, UserId userId, String courseId) {
this.id = id;
this.userId = userId;
this.courseId = courseId;
this.status = ConversationStatus.ACTIVE;
this.messages = new ArrayList<>();
this.createdAt = Instant.now();
this.updatedAt = Instant.now();
}
/**
* 用于从数据库重建的构造函数(仓储层使用)
*/
public Conversation(ConversationId id, UserId userId, String courseId,
ConversationStatus status, List<Message> messages,
int totalInputTokens, int totalOutputTokens,
Instant createdAt, Instant updatedAt) {
this.id = id;
this.userId = userId;
this.courseId = courseId;
this.status = status;
this.messages = new ArrayList<>(messages);
this.totalInputTokens = totalInputTokens;
this.totalOutputTokens = totalOutputTokens;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
// ====== 核心业务方法 ======
/**
* 添加用户提问
* 业务规则:
* 1. 对话必须是ACTIVE状态
* 2. 消息数量不能超过上限
*/
public void addUserQuestion(String content) {
validateActiveStatus();
validateMessageLimit();
validateContent(content);
Message message = Message.createUserMessage(MessageContent.of(content));
messages.add(message);
updatedAt = Instant.now();
addDomainEvent(new QuestionAskedEvent(id, userId, content, Instant.now()));
}
/**
* 添加AI回答
* 业务规则:必须先有用户提问,才能添加AI回答
*/
public void addAiAnswer(String content, TokenUsage tokenUsage) {
validateActiveStatus();
validateHasUserQuestion();
Message message = Message.createAiMessage(
MessageContent.of(content),
tokenUsage
);
messages.add(message);
// 累计Token消耗
this.totalInputTokens += tokenUsage.inputTokens();
this.totalOutputTokens += tokenUsage.outputTokens();
this.updatedAt = Instant.now();
addDomainEvent(new AnswerReceivedEvent(
id, userId, content, tokenUsage, Instant.now()
));
}
/**
* 关闭对话
*/
public void close() {
if (status != ConversationStatus.ACTIVE) {
throw new IllegalStateException("对话已关闭,无法重复关闭");
}
this.status = ConversationStatus.CLOSED;
this.updatedAt = Instant.now();
addDomainEvent(new ConversationClosedEvent(id, userId, Instant.now()));
}
/**
* 获取最后N条消息(用于构建上下文窗口)
*/
public List<Message> getLastMessages(int n) {
int size = messages.size();
int fromIndex = Math.max(0, size - n);
return Collections.unmodifiableList(messages.subList(fromIndex, size));
}
/**
* 获取Token使用摘要
*/
public TokenUsageSummary getTokenUsageSummary() {
return new TokenUsageSummary(
totalInputTokens,
totalOutputTokens,
totalInputTokens + totalOutputTokens
);
}
// ====== 业务规则验证(私有) ======
private void validateActiveStatus() {
if (status != ConversationStatus.ACTIVE) {
throw new DomainException("对话已关闭,无法继续提问");
}
}
private void validateMessageLimit() {
if (messages.size() >= MAX_MESSAGES_PER_CONVERSATION) {
throw new DomainException(
"对话消息数已达上限(" + MAX_MESSAGES_PER_CONVERSATION + "条),请开启新对话"
);
}
}
private void validateContent(String content) {
if (content == null || content.isBlank()) {
throw new DomainException("问题内容不能为空");
}
if (content.length() > 2000) {
throw new DomainException("问题内容过长(最多2000字)");
}
}
private void validateHasUserQuestion() {
if (messages.isEmpty()) {
throw new DomainException("请先提问再获取AI回答");
}
Message lastMessage = messages.get(messages.size() - 1);
if (lastMessage.getRole() != MessageRole.USER) {
throw new DomainException("不能连续添加AI回答");
}
}
// ====== 领域事件 ======
public List<Object> getDomainEvents() {
return Collections.unmodifiableList(domainEvents);
}
public void clearDomainEvents() {
domainEvents.clear();
}
private void addDomainEvent(Object event) {
domainEvents.add(event);
}
// ====== 枚举 ======
public enum ConversationStatus {
ACTIVE, CLOSED, ARCHIVED
}
}4.2 核心值对象
package com.laozhang.ai.domain.conversation;
import java.util.UUID;
/**
* 对话ID值对象
* 值对象:不可变、无唯一标识、通过属性值判断相等
*/
public record ConversationId(String value) {
public ConversationId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("ConversationId不能为空");
}
}
public static ConversationId generate() {
return new ConversationId(UUID.randomUUID().toString());
}
public static ConversationId of(String value) {
return new ConversationId(value);
}
@Override
public String toString() {
return value;
}
}package com.laozhang.ai.domain.shared;
/**
* Token使用量值对象
* 不可变:封装输入/输出Token数
*/
public record TokenUsage(int inputTokens, int outputTokens) {
public TokenUsage {
if (inputTokens < 0 || outputTokens < 0) {
throw new IllegalArgumentException("Token数量不能为负数");
}
}
public int totalTokens() {
return inputTokens + outputTokens;
}
public static TokenUsage of(int input, int output) {
return new TokenUsage(input, output);
}
public static TokenUsage zero() {
return new TokenUsage(0, 0);
}
/**
* Token加法(合并两次调用的Token)
*/
public TokenUsage add(TokenUsage other) {
return new TokenUsage(
this.inputTokens + other.inputTokens,
this.outputTokens + other.outputTokens
);
}
}package com.laozhang.ai.domain.conversation;
import com.laozhang.ai.domain.shared.TokenUsage;
import lombok.*;
import java.time.Instant;
import java.util.UUID;
/**
* 消息实体
* 实体:有唯一标识,有生命周期
*/
@Getter
public class Message {
private final String id;
private final MessageRole role;
private final MessageContent content;
private final TokenUsage tokenUsage;
private final Instant createdAt;
private Message(String id, MessageRole role, MessageContent content,
TokenUsage tokenUsage) {
this.id = id;
this.role = role;
this.content = content;
this.tokenUsage = tokenUsage;
this.createdAt = Instant.now();
}
public static Message createUserMessage(MessageContent content) {
return new Message(UUID.randomUUID().toString(),
MessageRole.USER, content, TokenUsage.zero());
}
public static Message createAiMessage(MessageContent content, TokenUsage tokenUsage) {
return new Message(UUID.randomUUID().toString(),
MessageRole.ASSISTANT, content, tokenUsage);
}
public String getTextContent() {
return content.text();
}
}五、领域服务:AI调用放在哪一层
这是DDD中最常见的争议点。先给出结论:
应用层(Application Service):
✅ 编排流程(先查知识库,再构建Prompt,再调用AI,再保存结果)
❌ 不包含业务规则
领域服务(Domain Service):
✅ 跨聚合的业务逻辑(如:检查用户今日提问限额)
✅ 构建Prompt的业务逻辑(Prompt本身是业务规则的一部分)
❌ 不直接调用AI模型(AI调用是基础设施层)
基础设施层(Infrastructure):
✅ 实际的AI模型调用(Spring AI ChatClient封装在此)
✅ 数据库读写
✅ Redis、MQ操作5.1 领域服务:提示词构建
package com.laozhang.ai.domain.conversation;
import com.laozhang.ai.domain.knowledge.KnowledgePoint;
import java.util.List;
/**
* 提示词构建领域服务
*
* 为什么是领域服务而不是应用服务?
* 因为Prompt构建逻辑包含业务规则:
* - 不同学科使用不同的教学风格
* - 不同学生等级使用不同的语言难度
* - 知识点的组织方式体现业务理解
*/
public class PromptBuildingService {
/**
* 构建教学对话的系统提示词
*/
public SystemPrompt buildSystemPrompt(String subject, StudentLevel level) {
String systemText = switch (subject) {
case "数学" -> buildMathSystemPrompt(level);
case "物理" -> buildPhysicsSystemPrompt(level);
case "编程" -> buildProgrammingSystemPrompt(level);
default -> buildDefaultSystemPrompt(subject, level);
};
return new SystemPrompt(systemText);
}
/**
* 构建带知识上下文的用户提示词
*/
public UserPrompt buildUserPromptWithContext(String question,
List<KnowledgePoint> relatedPoints) {
if (relatedPoints.isEmpty()) {
return new UserPrompt(question);
}
StringBuilder sb = new StringBuilder();
sb.append(question).append("\n\n");
sb.append("相关知识点参考:\n");
for (int i = 0; i < Math.min(relatedPoints.size(), 3); i++) {
KnowledgePoint point = relatedPoints.get(i);
sb.append(i + 1).append(". ")
.append(point.getTitle()).append(":")
.append(point.getSummary()).append("\n");
}
return new UserPrompt(sb.toString());
}
private String buildMathSystemPrompt(StudentLevel level) {
return String.format("""
你是一位耐心的数学老师,面对的是%s水平的学生。
教学原则:
1. 从具体例子入手,再推导公式
2. 一步步讲解,不跳步骤
3. 适时给出鼓励
4. %s
""",
level.getDescription(),
level == StudentLevel.BEGINNER ?
"使用简单易懂的语言,避免复杂术语" :
"可以使用专业术语,注重数学严密性"
);
}
private String buildPhysicsSystemPrompt(StudentLevel level) {
return "你是一位物理老师,善于用生活中的例子解释物理现象...";
}
private String buildProgrammingSystemPrompt(StudentLevel level) {
return "你是一位编程导师,擅长通过代码示例教学,提供可运行的代码...";
}
private String buildDefaultSystemPrompt(String subject, StudentLevel level) {
return String.format("你是一位%s老师,面对%s水平的学生,请用适合的方式解答问题。",
subject, level.getDescription());
}
// 值对象
public record SystemPrompt(String text) {}
public record UserPrompt(String text) {}
public enum StudentLevel {
BEGINNER("初学者"),
INTERMEDIATE("中等水平"),
ADVANCED("高级水平");
private final String description;
StudentLevel(String description) {
this.description = description;
}
public String getDescription() { return description; }
}
}5.2 领域服务:提问限额检查
package com.laozhang.ai.domain.conversation;
import lombok.*;
/**
* 提问限额检查领域服务
* 跨越User和Conversation两个聚合的业务规则放在领域服务中
*/
public class QuestionQuotaService {
/**
* 检查用户是否还有提问额度
*
* @param dailyQuestionCount 今日已提问次数
* @param userLevel 用户等级
* @return 检查结果
*/
public QuotaCheckResult checkDailyQuota(int dailyQuestionCount, UserLevel userLevel) {
int maxDailyQuestions = switch (userLevel) {
case FREE -> 5;
case BASIC -> 20;
case PREMIUM -> 100;
case UNLIMITED -> Integer.MAX_VALUE;
};
if (dailyQuestionCount >= maxDailyQuestions) {
return QuotaCheckResult.exceeded(dailyQuestionCount, maxDailyQuestions);
}
int remaining = maxDailyQuestions - dailyQuestionCount;
if (remaining <= 3) {
return QuotaCheckResult.warning(remaining, maxDailyQuestions);
}
return QuotaCheckResult.sufficient(remaining, maxDailyQuestions);
}
@Getter
@RequiredArgsConstructor
public static class QuotaCheckResult {
private final boolean allowed;
private final String status; // SUFFICIENT, WARNING, EXCEEDED
private final int remaining;
private final int limit;
private final String message;
public static QuotaCheckResult sufficient(int remaining, int limit) {
return new QuotaCheckResult(true, "SUFFICIENT", remaining, limit, null);
}
public static QuotaCheckResult warning(int remaining, int limit) {
return new QuotaCheckResult(true, "WARNING", remaining, limit,
"今日提问次数剩余" + remaining + "次");
}
public static QuotaCheckResult exceeded(int count, int limit) {
return new QuotaCheckResult(false, "EXCEEDED", 0, limit,
"今日提问次数已达上限(" + limit + "次),请明天再来或升级套餐");
}
}
public enum UserLevel { FREE, BASIC, PREMIUM, UNLIMITED }
}六、仓储模式:AI数据的持久化抽象
6.1 仓储接口(领域层)
package com.laozhang.ai.domain.conversation;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
/**
* 对话仓储接口
*
* 关键:接口定义在领域层,实现在基础设施层
* 领域层只知道"怎么用",不知道"怎么实现"(JPA/MongoDB/Redis随便换)
*/
public interface ConversationRepository {
/**
* 保存对话(新增或更新)
*/
void save(Conversation conversation);
/**
* 根据ID查询对话
*/
Optional<Conversation> findById(ConversationId id);
/**
* 查询用户在某课程的最近一个活跃对话
*/
Optional<Conversation> findLatestActiveByUserAndCourse(
UserId userId, String courseId);
/**
* 查询用户今日提问次数
* 注意:这是业务查询,而不是技术查询
*/
int countTodayQuestions(UserId userId, LocalDate date);
/**
* 查询用户的对话历史(分页)
*/
List<Conversation> findByUserId(UserId userId, int page, int pageSize);
}6.2 仓储实现(基础设施层)
package com.laozhang.ai.infrastructure.persistence;
import com.laozhang.ai.domain.conversation.*;
import com.laozhang.ai.domain.shared.*;
import com.laozhang.ai.infrastructure.persistence.po.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 对话仓储JPA实现
* 职责:在领域对象和持久化对象(PO)之间转换
*/
@Repository
@Slf4j
@RequiredArgsConstructor
public class ConversationRepositoryImpl implements ConversationRepository {
private final ConversationJpaRepository jpaRepository;
private final MessageJpaRepository messageJpaRepository;
@Override
public void save(Conversation conversation) {
// 将领域对象转换为PO
ConversationPO po = toPersistenceObject(conversation);
jpaRepository.save(po);
// 保存消息
List<MessagePO> messagePOs = conversation.getMessages().stream()
.map(m -> toMessagePO(m, po.getId()))
.collect(Collectors.toList());
messageJpaRepository.saveAll(messagePOs);
log.debug("[REPO] Conversation saved: id={}", conversation.getId());
}
@Override
public Optional<Conversation> findById(ConversationId id) {
return jpaRepository.findByConversationId(id.value())
.map(this::toDomainObject);
}
@Override
public Optional<Conversation> findLatestActiveByUserAndCourse(
UserId userId, String courseId) {
return jpaRepository.findFirstByUserIdAndCourseIdAndStatusOrderByCreatedAtDesc(
userId.value(), courseId, "ACTIVE")
.map(this::toDomainObject);
}
@Override
public int countTodayQuestions(UserId userId, LocalDate date) {
return jpaRepository.countTodayQuestionsByUserId(userId.value(), date);
}
@Override
public List<Conversation> findByUserId(UserId userId, int page, int pageSize) {
return jpaRepository.findByUserIdOrderByCreatedAtDesc(
userId.value(),
org.springframework.data.domain.PageRequest.of(page, pageSize))
.stream()
.map(this::toDomainObject)
.collect(Collectors.toList());
}
// ====== 对象转换方法 ======
private ConversationPO toPersistenceObject(Conversation conversation) {
ConversationPO po = new ConversationPO();
po.setConversationId(conversation.getId().value());
po.setUserId(conversation.getUserId().value());
po.setCourseId(conversation.getCourseId());
po.setStatus(conversation.getStatus().name());
po.setTotalInputTokens(conversation.getTotalInputTokens());
po.setTotalOutputTokens(conversation.getTotalOutputTokens());
po.setCreatedAt(conversation.getCreatedAt()
.atZone(ZoneId.systemDefault()).toLocalDateTime());
po.setUpdatedAt(conversation.getUpdatedAt()
.atZone(ZoneId.systemDefault()).toLocalDateTime());
return po;
}
private Conversation toDomainObject(ConversationPO po) {
// 加载关联消息
List<MessagePO> messagePOs = messageJpaRepository
.findByConversationIdOrderByCreatedAt(po.getId());
List<Message> messages = messagePOs.stream()
.map(this::toMessageDomain)
.collect(Collectors.toList());
return new Conversation(
ConversationId.of(po.getConversationId()),
UserId.of(po.getUserId()),
po.getCourseId(),
Conversation.ConversationStatus.valueOf(po.getStatus()),
messages,
po.getTotalInputTokens(),
po.getTotalOutputTokens(),
po.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant(),
po.getUpdatedAt().atZone(ZoneId.systemDefault()).toInstant()
);
}
private Message toMessageDomain(MessagePO po) {
MessageContent content = MessageContent.of(po.getContent());
TokenUsage tokenUsage = TokenUsage.of(po.getInputTokens(), po.getOutputTokens());
return MessageRole.USER.name().equals(po.getRole())
? Message.createUserMessage(content)
: Message.createAiMessage(content, tokenUsage);
}
private MessagePO toMessagePO(Message message, Long conversationDbId) {
MessagePO po = new MessagePO();
po.setMessageId(message.getId());
po.setConversationId(conversationDbId);
po.setRole(message.getRole().name());
po.setContent(message.getTextContent());
po.setInputTokens(message.getTokenUsage().inputTokens());
po.setOutputTokens(message.getTokenUsage().outputTokens());
po.setCreatedAt(message.getCreatedAt()
.atZone(ZoneId.systemDefault()).toLocalDateTime());
return po;
}
}七、应用服务:编排领域逻辑
package com.laozhang.ai.application.service;
import com.laozhang.ai.application.command.AskQuestionCommand;
import com.laozhang.ai.domain.conversation.*;
import com.laozhang.ai.domain.knowledge.*;
import com.laozhang.ai.domain.shared.*;
import com.laozhang.ai.infrastructure.ai.AiModelPort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
/**
* 提问应用服务
*
* 应用服务的职责:
* 1. 接收命令(Command)
* 2. 加载聚合根
* 3. 调用领域服务
* 4. 调用领域方法(聚合根)
* 5. 调用基础设施(AI模型、仓储)
* 6. 发布领域事件
*
* 注意:应用服务不包含业务规则,业务规则在领域层
*/
@Service
@Slf4j
@RequiredArgsConstructor
@Transactional
public class AskQuestionApplicationService {
// 领域仓储接口
private final ConversationRepository conversationRepository;
private final KnowledgeBaseRepository knowledgeBaseRepository;
// 领域服务
private final PromptBuildingService promptBuildingService;
private final QuestionQuotaService quotaService;
// 基础设施端口(AI模型调用)
private final AiModelPort aiModelPort;
// 事件发布
private final ApplicationEventPublisher eventPublisher;
/**
* 处理用户提问
*/
public AskQuestionResult handle(AskQuestionCommand command) {
UserId userId = UserId.of(command.getUserId());
// 1. 检查提问限额(领域服务)
int todayCount = conversationRepository.countTodayQuestions(userId, LocalDate.now());
var quotaResult = quotaService.checkDailyQuota(
todayCount,
QuestionQuotaService.UserLevel.valueOf(command.getUserLevel())
);
if (!quotaResult.isAllowed()) {
throw new DomainException(quotaResult.getMessage());
}
// 2. 获取或创建对话(聚合根)
Conversation conversation = conversationRepository
.findLatestActiveByUserAndCourse(userId, command.getCourseId())
.orElseGet(() -> Conversation.create(userId, command.getCourseId()));
// 3. 添加用户提问(聚合根业务方法)
conversation.addUserQuestion(command.getQuestion());
// 4. 从知识库检索相关内容(基础设施)
List<KnowledgePoint> relatedPoints = knowledgeBaseRepository
.searchRelated(command.getCourseId(), command.getQuestion(), 3);
// 5. 构建提示词(领域服务)
var systemPrompt = promptBuildingService.buildSystemPrompt(
command.getSubject(),
PromptBuildingService.StudentLevel.valueOf(command.getStudentLevel())
);
var userPrompt = promptBuildingService.buildUserPromptWithContext(
command.getQuestion(), relatedPoints
);
// 6. 调用AI模型(基础设施端口)
AiModelPort.AiCallResult aiResult = aiModelPort.call(
systemPrompt.text(),
userPrompt.text(),
conversation.getLastMessages(10)
);
// 7. 添加AI回答(聚合根业务方法)
conversation.addAiAnswer(
aiResult.content(),
TokenUsage.of(aiResult.inputTokens(), aiResult.outputTokens())
);
// 8. 持久化(仓储)
conversationRepository.save(conversation);
// 9. 发布领域事件
conversation.getDomainEvents().forEach(eventPublisher::publishEvent);
conversation.clearDomainEvents();
// 10. 构建返回结果
return AskQuestionResult.builder()
.conversationId(conversation.getId().value())
.answer(aiResult.content())
.tokenUsage(conversation.getTokenUsageSummary())
.quotaWarning(quotaResult.getStatus().equals("WARNING") ?
quotaResult.getMessage() : null)
.build();
}
}7.1 Command对象
package com.laozhang.ai.application.command;
import lombok.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 提问命令
* Command是应用服务的输入,对应用例中的一个操作
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AskQuestionCommand {
@NotBlank(message = "用户ID不能为空")
private String userId;
@NotBlank(message = "课程ID不能为空")
private String courseId;
@NotBlank(message = "问题不能为空")
@Size(max = 2000, message = "问题最多2000字")
private String question;
@NotBlank
private String subject; // 学科
private String studentLevel; // 学生水平
private String userLevel; // 用户等级(免费/付费)
}八、AI基础设施端口与适配器
package com.laozhang.ai.infrastructure.ai;
import com.laozhang.ai.domain.conversation.Message;
import com.laozhang.ai.domain.conversation.MessageRole;
import java.util.List;
/**
* AI模型端口(Port)接口
*
* DDD中的端口-适配器模式(Hexagonal Architecture):
* - Port:领域层定义的接口("我需要调用AI,需要这些能力")
* - Adapter:基础设施层的实现("我用Spring AI实现这个接口")
*
* 这样领域层完全不知道Spring AI的存在,
* 以后换成LangChain4j或其他框架,只需换Adapter
*/
public interface AiModelPort {
/**
* 调用AI模型
*
* @param systemPrompt 系统提示词
* @param userMessage 用户消息
* @param history 历史对话(用于多轮上下文)
* @return AI调用结果
*/
AiCallResult call(String systemPrompt, String userMessage, List<Message> history);
/**
* 流式调用AI模型
*/
reactor.core.publisher.Flux<String> stream(String systemPrompt, String userMessage,
List<Message> history);
/**
* AI调用结果值对象
*/
record AiCallResult(
String content,
int inputTokens,
int outputTokens,
long elapsedMs
) {}
}package com.laozhang.ai.infrastructure.ai;
import com.laozhang.ai.domain.conversation.Message;
import com.laozhang.ai.domain.conversation.MessageRole;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.*;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.List;
/**
* Spring AI适配器
* 将Spring AI的ChatClient适配到AiModelPort接口
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class SpringAiModelAdapter implements AiModelPort {
private final ChatClient chatClient;
@Override
public AiCallResult call(String systemPrompt, String userMessage, List<Message> history) {
long start = System.currentTimeMillis();
try {
// 构建历史消息
List<org.springframework.ai.chat.messages.Message> springMessages =
convertToSpringMessages(history);
ChatResponse response = chatClient.prompt()
.system(systemPrompt)
.messages(springMessages)
.user(userMessage)
.call()
.chatResponse();
String content = response.getResult().getOutput().getContent();
int inputTokens = extractInputTokens(response);
int outputTokens = extractOutputTokens(response);
long elapsed = System.currentTimeMillis() - start;
return new AiCallResult(content, inputTokens, outputTokens, elapsed);
} catch (Exception e) {
log.error("[AI-ADAPTER] Call failed: {}", e.getMessage(), e);
throw new RuntimeException("AI调用失败: " + e.getMessage(), e);
}
}
@Override
public Flux<String> stream(String systemPrompt, String userMessage, List<Message> history) {
List<org.springframework.ai.chat.messages.Message> springMessages =
convertToSpringMessages(history);
return chatClient.prompt()
.system(systemPrompt)
.messages(springMessages)
.user(userMessage)
.stream()
.content();
}
private List<org.springframework.ai.chat.messages.Message> convertToSpringMessages(
List<Message> domainMessages) {
List<org.springframework.ai.chat.messages.Message> springMessages = new ArrayList<>();
for (Message msg : domainMessages) {
if (msg.getRole() == MessageRole.USER) {
springMessages.add(new UserMessage(msg.getTextContent()));
} else {
springMessages.add(new AssistantMessage(msg.getTextContent()));
}
}
return springMessages;
}
private int extractInputTokens(ChatResponse response) {
try {
return response.getMetadata().getUsage().getPromptTokens().intValue();
} catch (Exception e) { return 0; }
}
private int extractOutputTokens(ChatResponse response) {
try {
return response.getMetadata().getUsage().getGenerationTokens().intValue();
} catch (Exception e) { return 0; }
}
}九、CQRS:AI读写分离
package com.laozhang.ai.application.service;
import com.laozhang.ai.application.query.GetConversationHistoryQuery;
import com.laozhang.ai.domain.conversation.ConversationId;
import com.laozhang.ai.domain.conversation.ConversationRepository;
import com.laozhang.ai.domain.shared.UserId;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 对话查询服务(CQRS中的Q)
*
* 特点:
* 1. 只读,不修改任何状态
* 2. 可以直接返回DTO,不需要经过聚合根
* 3. 可以使用更高效的查询(不加载完整聚合)
* 4. 可以从缓存或只读副本查询
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ConversationQueryService {
private final ConversationRepository conversationRepository;
/**
* 获取对话历史
* 直接返回消息列表DTO,不经过Conversation聚合根的业务方法
*/
public List<MessageDTO> getConversationHistory(GetConversationHistoryQuery query) {
return conversationRepository.findById(
ConversationId.of(query.getConversationId()))
.map(conv -> conv.getMessages().stream()
.map(m -> new MessageDTO(
m.getId(),
m.getRole().name(),
m.getTextContent(),
m.getCreatedAt().toString()
))
.toList())
.orElse(List.of());
}
/**
* 获取用户对话列表(分页)
*/
public List<ConversationSummaryDTO> getUserConversations(
String userId, int page, int pageSize) {
return conversationRepository
.findByUserId(UserId.of(userId), page, pageSize)
.stream()
.map(conv -> new ConversationSummaryDTO(
conv.getId().value(),
conv.getCourseId(),
conv.getMessages().size(),
conv.getTokenUsageSummary().totalTokens(),
conv.getStatus().name(),
conv.getCreatedAt().toString()
))
.toList();
}
public record MessageDTO(String id, String role, String content, String createdAt) {}
public record ConversationSummaryDTO(String conversationId, String courseId,
int messageCount, int totalTokens,
String status, String createdAt) {}
}十、接口层:REST Controller
package com.laozhang.ai.interfaces.rest;
import com.laozhang.ai.application.command.AskQuestionCommand;
import com.laozhang.ai.application.service.*;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.List;
/**
* 对话REST接口
* 接口层只负责:接收HTTP请求、转换DTO、委托给应用服务、返回HTTP响应
*/
@RestController
@RequestMapping("/api/v1/conversations")
@RequiredArgsConstructor
public class ConversationController {
private final AskQuestionApplicationService askQuestionService;
private final ConversationQueryService queryService;
/**
* 提问接口
*/
@PostMapping("/ask")
public ResponseEntity<AskQuestionResult> askQuestion(
@Valid @RequestBody AskQuestionRequest request,
@RequestHeader("X-User-Id") String userId,
@RequestHeader("X-User-Level") String userLevel) {
AskQuestionCommand command = AskQuestionCommand.builder()
.userId(userId)
.courseId(request.getCourseId())
.question(request.getQuestion())
.subject(request.getSubject())
.studentLevel(request.getStudentLevel())
.userLevel(userLevel)
.build();
AskQuestionResult result = askQuestionService.handle(command);
return ResponseEntity.ok(result);
}
/**
* 获取对话历史
*/
@GetMapping("/{conversationId}/messages")
public ResponseEntity<List<ConversationQueryService.MessageDTO>> getHistory(
@PathVariable String conversationId) {
var query = new com.laozhang.ai.application.query.GetConversationHistoryQuery(conversationId);
return ResponseEntity.ok(queryService.getConversationHistory(query));
}
}十一、DDD重构前后对比
11.1 代码质量对比
| 维度 | 重构前(大泥球) | 重构后(DDD) |
|---|---|---|
| 最大Service行数 | 2800行 | 120行(应用服务) |
| 业务规则位置 | 散落在Service中 | 集中在聚合根和领域服务 |
| 单元测试难度 | 需要Mock 8个依赖 | 领域层纯Java对象,零Mock |
| AI调用层 | 直接在Service中 | 基础设施适配器隔离 |
| 更换AI框架影响 | 全Service修改 | 只改Adapter(1个类) |
| 业务逻辑复用 | 复制粘贴 | 领域服务复用 |
11.2 测试覆盖率改善
DDD重构后,纯领域层(无Spring依赖)的单元测试:
@Test
void conversation聚合根应该正确维护消息限制() {
// 纯Java测试,不需要Spring容器,不需要Mock
Conversation conv = Conversation.create(
UserId.of("user-001"), "course-java-001"
);
// 添加100条消息...
for (int i = 0; i < 100; i++) {
conv.addUserQuestion("问题" + i);
conv.addAiAnswer("回答" + i, TokenUsage.of(10, 50));
}
// 第101条应该抛出异常
assertThatThrownBy(() -> conv.addUserQuestion("第101个问题"))
.isInstanceOf(DomainException.class)
.hasMessageContaining("消息数已达上限");
}领域层测试不需要启动Spring容器,测试执行时间:38ms(vs 集成测试的12秒)。
十二、FAQ
Q1:DDD是不是过度设计?AI应用适合用DDD吗?
A:对于简单的CRUD型AI应用(如一个简单的聊天机器人),DDD确实过度设计。但当AI应用有复杂的业务规则(用量管控、多角色对话、知识库多租户管理)时,DDD是非常值得的投资。判断标准:如果你的Service类超过500行、修改一处需要担心其他地方,就值得考虑DDD了。
Q2:AI调用应该放在领域层还是基础设施层?
A:基础设施层,通过端口(接口)+适配器模式隔离。理由:AI调用是I/O操作,依赖外部系统(AI模型厂商),属于基础设施关注点。领域层不应该依赖任何I/O框架(Spring AI、LangChain4j等)。
Q3:值对象和实体怎么区分?
A:简单规则:有唯一标识(ID)且有独立生命周期的是实体;没有独立标识、通过属性值判断相等、不可变的是值对象。例如:Message是实体(有ID,生命周期与Conversation关联);TokenUsage是值对象(只是数字的组合,可替换);MessageContent是值对象(相同内容就是相同的值)。
Q4:领域事件在DDD中什么时候发布?
A:在聚合根的业务方法中产生领域事件(本文addDomainEvent方法),在应用服务中统一发布(publishEvent)。不要在聚合根构造函数中直接发布,因为此时事务还未提交。
Q5:仓储接口应该返回Optional还是抛异常?
A:查询类方法(findById)返回Optional,表示"可能存在可能不存在"。如果业务要求必须存在(否则是业务错误),在应用服务或聚合根中处理Optional.empty()的情况并抛出领域异常。
结尾
老周用DDD重构了AI教辅系统。原来2800行的Service类被拆分成:
- 聚合根
Conversation(180行,包含所有业务规则) - 应用服务
AskQuestionApplicationService(120行,只做编排) - 基础设施适配器
SpringAiModelAdapter(80行,只做AI调用)
修改业务规则只改领域层;更换AI模型只改适配器;新增查询只改Query服务。
这就是DDD的价值:让每个代码只有一个修改的理由。
