浅尝领域驱动设计:DDD是AI编程-上下文工程的良好框架

传统上,搭建一个后端WEB项目从MVC+三层架构开始,而见识过AI的效率后,只要提示得当5分钟的生成就顶过我一天的工作量,因此我写了个DEMO,又写了个文档,然后就试图让流行的AI工具帮我快速实现。不出所料,在没有严格限定和充分审查的情况下,AI很快把项目改得面目全非,它能实现一个勉强能用的DEMO(所谓的氛围编程),但几乎不可维护。

然后我不得不重构项目,并开始思考:什么是AI介入后端的良好范式?

就这样我不由自主的走上了领域驱动设计(DDD)的路线,以下是我的一点探索和浅见。

在我看来,DDD 是后端项目负责人想要掌控项目和IT企业想要掌控核心资产的黄金实践。DDD理想情况下需要项目负责人具备领域专家的业务能力,它更适合那种在垂直领域扎根并依赖底层技术创新来扩展业务的项目。现在它也适合AI辅助编程,当然也可以通过AI辅助、迭代调整来逐步建立领域知识——关键是要有"追求理解业务本质"的意识。

依赖倒置:让业务逻辑不受框架绑架

传统三层架构的依赖链条:

UI层 → 业务逻辑层 → 数据访问层 → 数据库

换个ORM框架?改业务代码。数据库从MySQL换PostgreSQL?改业务代码。业务逻辑散落各层?到处都要改。中间的业务逻辑就是块夹心饼干,干脆敞开了,模型谁都可以用,跨层调用?随便,怎么方便怎么来。

DDD通过依赖倒置,让领域层定义接口,基础设施层去实现:

// 领域层定义接口(不依赖任何框架)
namespace NamBlog.Domain.Interfaces
{
    public interface IPostRepository
    {
        Task<Post?> GetByIdAsync(int postId);
        Task SaveAsync(Post post);
    }
}
// 基础设施层实现(可以随意切换实现)
namespace NamBlog.Infrastructure.Persistence
{
    public class EFPostRepository : IPostRepository  // 用EF Core实现
    {
        private readonly BlogContext _context;
        // ...
    }
    // 将来可以换成 Dapper、MongoDB、内存存储,领域层代码不变
}

好处:

  • 领域层只依赖核心的语言特性和开发框架,长期稳定,减少技术升级的成本,也可以独立测试。
  • 换实现框架只需修改基础设施层。
  • .NET的领域模型可以被Go、Rust等其他语言平台重新实现(业务规则复用),就像乐高积木可以在不同场景中复用核心模块。

代码即文档:充血模型的自解释性

在我的NamBlog项目中,如果你问"文章能做哪些操作?",不需要翻文档,看Post类就够了:

public class Post
{
    // 10个业务方法穷举了所有关键业务操作
    public void UpdateMetadata(...)     // 改元数据
    public PostVersion SubmitNewVersion() // 提交新版本
    public void Publish(int versionId)   // 发布
    public void Unpublish()             // 取消发布
    public void Feature()               // 设为精选
    // ...
}

对比贫血模型

// 贫血模型:Post只是数据容器
public class Post
{
    public int Id { get; set; }
    public bool IsPublished { get; set; }  // 谁都能改
}
// 业务逻辑散落在各个Service里
public class PostService
{
    public void Publish(int postId) { ... }  // 在这
}
public class ArticleManager  
{
    public void PublishArticle(int id) { ... }  // 还在这?
}
// 3个月后自己都不记得怎么回事

充血模型的好处是:看代码就知道能做什么,不能做什么。

整洁架构与六边形架构:殊途同归

DDD经常和整洁架构、六边形架构一起提到,它们的核心思想是一致的:

六边形架构

外部系统(数据库、API、UI)→ 适配器层 → 端口/接口 → 核心业务逻辑

整洁架构

同心圆结构:
外层 → 框架&驱动→ 接口适配器 → 应用业务规则
内层 → 企业业务规则

在NamBlog中的映射:

基础设施层(Infrastructure) → 六边形的"适配器"、整洁架构的"框架层"
领域接口(Interfaces)       → 六边形的"端口"
领域层(Domain)             → 整洁架构的"企业业务规则"
应用层(Application)        → 整洁架构的"用例层"

核心都是:业务规则在最内层,不依赖外部任何东西。外部世界可以随便换,业务规则不动。

我的实践:NamBlog的领域设计

说明:文中代码示例为了便于理解做了适当简化,完整实现请参考 GitHub 仓库

从业务出发而不是数据库

NamBlog的核心业务流程:

写作者创建Markdown → AI转换成HTML → 管理多个版本 → 选择发布

一开始我也习惯从数据库设计开始,但转念一想:业务的核心是什么?

3个关键的业务规则:

  1. 一篇文章可以有多个版本,但同时只能发布1个版本
  2. Markdown是文章本体,HTML是派生物
  3. 发布/取消发布不影响版本历史(随时可回溯)

这些规则怎么体现在代码中?

public class Post  // 聚合根
{
    // 规则1: MainVersionId是单值,确保同时只有一个已发布版本
    public int? MainVersionId { get; private set; }
    public PostVersion? MainVersion { get; private set; }
    
    // 规则2: Versions集合保存所有历史
    public ICollection<PostVersion> Versions { get; private set; }
    
    // 规则3: 发布状态独立于版本
    public bool IsPublished { get; private set; }
    public DateTimeOffset? PublishedAt { get; private set; }
    
    // 业务方法封装操作
    public void Publish(int versionId)
    {
        var version = Versions.FirstOrDefault(v => v.VersionId == versionId);
        if (version == null) throw new DomainException("版本不存在");
        if (string.IsNullOrEmpty(Slug)) throw new DomainException("Slug不能为空");
        MainVersionId = versionId;
        IsPublished = true;
        PublishedAt = DateTimeOffset.UtcNow;
    }
    public void Unpublish()
    {
        IsPublished = false;
        // 注意:MainVersionId保留,可以快速重新发布同一版本
    }
}

关键点:

  • private set 防止外部绕过业务规则
  • 业务方法中的检查保护了不变式
  • 看代码就能理解业务:发布需要版本ID和Slug,取消发布保留主版本

两个工厂方法:业务场景的区分

NamBlog支持两种创建文章的方式:

// Web编辑器:用户手动创建
public static Post CreateFromUserInput(
    string fileName,
    string author,
    string? category)
{
    return new Post(
        fileName: fileName,
        filePath: string.Empty,
        author: author,
        category: category
    );
}
// Obsidian同步:文件系统监控
public static Post CreateFromFileSystem(
    string fileName,
    string? filePath,
    string author)
{
    return new Post(
        fileName: fileName,
        filePath: filePath,  // 保留文件路径
        author: author,
        category: filePath ?? "Unclassified"  // 路径即分类
    );
}

为什么要两个工厂方法?

最初我想用一个方法+参数区分,但后来发现业务语义不同:

  • Web创建:立即可编辑,分类由用户选
  • 文件同步:路径即分类,支持文件夹层级

如果用贫血模型,这些差异会藏在Service的if-else里,很难直观理解为什么这样判断。

务实的妥协

理想的DDD领域层应该完全独立于框架,但DDD也具有灵活性,NamBlog作为MVP,我做了妥协:

// 领域层:没有任何框架依赖
public class Post
{
    public int PostId { get; private set; }  // 纯C#属性
    public void Publish(int versionId) { ... }  // 业务方法
}
// 基础设施层:用Fluent API配置映射,没用自己的POCO
public class PostConfiguration : IEntityTypeConfiguration<Post>
{
    public void Configure(EntityTypeBuilder<Post> builder)
    {
        builder.HasKey(p => p.PostId);
        builder.HasIndex(p => p.Title).IsUnique();
        // ...
    }
}

原则是,领域层有没有外层依赖?如果没有,妥协是合理的。

  • ✅ 可以接受:ORM注解、导航属性
  • ❌ 不能接受:在领域方法里写SQL、依赖DbContext

总结DDD容易踩的坑

坑1:没理清业务边界导致理解偏差

一开始我把"文章版本"(PostVersion)设计为值对象,理由是"它依附于Post存在"。结果写了一半发现:

  • 版本需要独立的ID(用户要切换版本)
  • 版本有自己的生命周期(可以被删除)
  • 版本需要关联AI生成记录

我混淆了"依附关系"和"实体/值对象"的判断标准。

正确判断:

值对象:没有唯一标识,通过属性判断相等
实体:  有唯一标识,即使属性相同也是不同对象

PostVersion有VersionId → 是实体
就算两个版本内容完全相同,也是两个不同的版本

教训:不要想当然,要问自己"这个东西需要被独立识别吗?"

坑2:命名不规范引发误解

反面案例:

// 糟糕的命名
public class Post
{
    public void Change(int id) { ... }  // 改什么?版本?状态?
    public void Update() { ... }        // 更新什么?
    public void Process() { ... }       // 处理什么业务?
}

好的命名应该体现业务意图:

public class Post
{
    public void Publish(int versionId)    // 清楚:发布指定版本
    public void UpdateMetadata(...)       // 清楚:只改元数据
    public void SubmitNewVersion()        // 清楚:提交新版本
}

我的经验:方法名应该让AI(或产品经理)能秒懂。 如果你写的方法名需要技术背景才能理解,可能就有问题。

坑3:过度设计验证规则

案例:

// 过度设计
public class Slug : ValueObject
{
    private readonly string _value;
    private Slug(string value) { _value = value; }
    public static Slug Create(string value)
    {
        if (string.IsNullOrEmpty(value)) throw ...
        if (!Regex.IsMatch(value, "^[a-z0-9-]+$")) throw ...
        return new Slug(value);
    }
    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return _value;
    }
}

为了一个验证逻辑,写了20行代码。在MVP阶段,这是过度设计。

我的做法:

public class Post
{
    public string? Slug { get; private set; }
    
    public void UpdateMetadata(string? slug = null, ...)
    {
        if (slug != null)
        {
            ValidateSlug(slug);  // 验证逻辑放在这里
            Slug = slug;
        }
    }
    
    private static void ValidateSlug(string slug)
    {
        if (!ValidationRuleset.Post.Slug.IsValid(slug))
                throw new ArgumentException(
                    ValidationRuleset.Post.Slug.GetValidationError(slug, "Slug"),
                    nameof(slug));
    }
}

何时用值对象? 当它在多个聚合中复用,且有复杂的行为时。否则简单属性+验证方法就够了。

坑4:忽略了业务语言

DDD强调"统一语言"(Ubiquitous Language),但我一开始没太重视。

反面案例:

// 我的早期代码
public void SetMainVersion(int versionId) { ... }

// "什么叫'设置主版本'?

修正后:

public void Publish(int versionId) { ... }  // 使用业务术语

教训:代码中的术语应该和产品、运营、业务人员的语言一致。如果他们说"发布",你就别写SetMainVersion

坑5:聚合边界划分不清

困惑:评论(Comment)应该是Post的一部分,还是独立聚合根?

错误思路:"评论依附于文章,所以应该是Post的子实体"

// 错误设计
public class Post
{
    public ICollection<Comment> Comments { get; set; }  // 文章加载就加载所有评论?
}

问题:

  • 文章有1000条评论,每次加载Post都要加载1000条评论?
  • 删除评论需要先加载Post,修改集合,再保存整个Post?
  • 评论的并发控制怎么办(多人同时评论)?

正确做法:评论是独立聚合根(如果需要的话)

public class Comment  // 独立聚合
{
    public int CommentId { get; private set; }
    public int PostId { get; private set; }  // 只引用Post的ID,不是导航属性
    
    public void Delete()
    {
        IsDeleted = true;  // 独立操作,不影响Post
    }
}

我一开始也想加评论功能,但后来决定不在后端实现,原因是:

  1. NamBlog的核心是"用AI生成精美HTML",评论会破坏HTML的原汁原味
  2. 如果用户需要,可以在提示词中让AI生成时内嵌评论系统
  3. 避免聚合边界的复杂性,保持Post聚合根的纯粹性

判断标准:

  • 能否独立于父实体进行操作?(能 → 独立聚合)
  • 加载父实体时是否总是需要子实体?(否 → 独立聚合)
  • 子实体的数量是否无上限?(是 → 独立聚合)

DDD与AI编程:一些体会

为什么不应该写大量的中间文档

很多人刚开始用AI编程时,会习惯写大量文档——觉得这样能解释清楚、约束严格一点。AI开发工具(如Copilot)也倾向于生成大量文档。

问题在于:

  1. 时间成本高:写和读这些文档花费大量时间
  2. 文档会过期:代码一改,文档就作废了,不但没有参考价值,反而成为干扰
  3. 上下文污染:AI会因为过时文档中的错误生成更多错误内容,幻觉加剧

这也许是为什么项目做着做着感觉"AI变蠢了"的原因之一——不是AI变蠢了,是上下文被污染了。

DDD的优势:代码即文档。这也是不懂编程的小白和开发人员的最大区别——后者可以不依赖文档用结构更严谨的代码和AI沟通。

为什么RAG索引代码不如索引领域模型

让AI基于项目文档生成代码,问题很大:

文档可能的问题:

"发布文章的流程" 可能在3个文档中:
- 需求文档:点击发布按钮后...
- 数据库设计:published字段设为true...
- API文档:POST /api/posts/{id}/publish...

RAG检索到这3段,AI混淆了层次,生成四不像的代码

领域模型的优势:

// AI只需要看这个
public class Post
{
    public void Publish(int versionId) 
    {
        // 所有发布的业务规则都在这里
    }
}

上下文清晰、边界明确,AI幻觉大幅减少。

DDD如何约束AI生成的代码

场景对比:**让AI实现文章发布功能

传统方式:

//你: "实现文章发布功能"
//AI生成:
public void PublishPost(int postId) 
{
    var post = _db.Posts.Find(postId);
    post.IsPublished = true;  // 忘记检查版本
    _db.SaveChanges();
}

DDD方式:

//你: "实现Post.Publish(int versionId)方法"
//AI生成:
public void Publish(int versionId)
{
    var version = Versions.FirstOrDefault(v => v.VersionId == versionId);
    if (version == null) throw ...  // 方法签名约束了必须考虑版本
    
    MainVersionId = versionId;
    IsPublished = true;
}

差异:

  • 方法签名就是约束(必须传versionId)
  • private set 阻止AI写出post.IsPublished = true这种绕过业务规则的代码
  • AI只能在方法体内实现细节

和AI探讨业务的正确姿势

即便是业务领域专家,也不太可能一开始就掌握了业务的所有细节,肯定要边实现边调整的。AI一开始就可以介入项目开发,从充血模型设计开始,把业务聊通,聊透。但需要注意的,每个人对业务的理解是不一样的,AI也不是专家,它能一下子拿出10个看起来很对的主意,而且无缝在这些主意间切换,所以还要靠人自己的洞察。

❌ 错误:让AI当领域专家

你: "帮我设计博客的领域模型"
AI: "建议Post聚合根包含PostMetadata值对象、PostContent实体..."
你: "听起来很专业!"

→ AI给的是教科书式过度设计

✅ 正确:用AI验证你的想法

你: "文章有两种创建方式:
     1. Web编辑器 - 用户立即可编辑
     2. Obsidian同步 - 文件路径作为分类
     应该用不同的工厂方法吗?"
AI: "是的,建议CreateFromUserInput和CreateFromFileSystem"
你: "但Obsidian用户可能没设分类,需要默认值"

→ 你在澄清业务规则,AI在帮你验证设计

总结个人体会

DDD不是银弹,掌握精髓最重要

我的NamBlog并不是"纯正的DDD":

  • 基础层复用领域模型(没用自己的POCO)
  • 没有用领域事件(暂时用不上)
  • 没有复杂的值对象(MVP够用就行)

但我觉得这不重要。DDD的精髓是:

  1. 业务规则封装在领域层,不散落在Service
  2. 依赖方向正确,领域层不依赖框架
  3. 代码体现业务语言,看代码就懂业务

做到这3点,就比传统贫血模型强太多了。

我的原则:先保证业务规则集中管理,再逐步引入DDD的其他模式。

AI时代DDD的价值

传统开发中,DDD的收益可能不明显(写充血模型确实比贫血模型麻烦)。

但AI时代不一样:

  • AI需要清晰的边界:聚合根天然提供了这种边界
  • AI需要稳定的上下文:领域模型比文档稳定
  • AI容易幻觉:private set + 业务方法 = 物理约束

所以我觉得,DDD在AI编程中的价值有很大挖掘空间。

posted @ 2026-01-05 23:29  怀川  阅读(13)  评论(0)    收藏  举报