[{"data":1,"prerenderedAt":1182},["ShallowReactive",2],{"blog-ddd-go-project-structure":3},{"id":4,"title":5,"body":6,"category":1168,"date":1169,"description":81,"extension":1170,"meta":1171,"navigation":210,"path":1172,"seo":1173,"series":1174,"seriesOrder":244,"stem":1175,"tags":1176,"__hash__":1181},"blog\u002Fblog\u002Fddd-go-project-structure.md","DDD 实战（八）：Go 项目中的 DDD 代码组织",{"type":7,"value":8,"toc":1156},"minimark",[9,13,28,31,34,42,50,53,66,71,74,85,99,117,120,124,131,137,140,166,169,175,178,182,187,190,493,496,500,515,518,550,553,617,620,624,627,630,805,808,811,815,818,821,921,928,932,935,941,951,958,965,968,982,986,989,1006,1009,1026,1029,1032,1036,1042,1058,1064,1070,1076,1089,1093,1096,1102,1105,1111,1114,1134,1137,1140,1146,1152],[10,11,5],"h1",{"id":12},"ddd-实战八go-项目中的-ddd-代码组织",[14,15,16],"blockquote",{},[17,18,19,20,27],"p",{},"推荐 ",[21,22,26],"a",{"href":23,"rel":24},"https:\u002F\u002Fbewild.ai?code=BYZDOTME",[25],"nofollow","BeWild"," 代充 Codex ，工作猛猛提效。",[17,29,30],{},"前面七篇讲了 DDD 的概念、协同办公 IM 的业务域和部署模块。这一篇落到 Go 项目。",[17,32,33],{},"Go 里实践 DDD，要特别小心一件事：不要把 Java 的写法原样搬过来。",[17,35,36,37],{},"Go 的包、接口、组合、错误处理、依赖管理都有自己的习惯。Go 官方的模块布局文档也强调，项目结构应该从简单开始，随着包和命令增多再演进，而不是一开始就套复杂模板。",[21,38,41],{"href":39,"rel":40},"https:\u002F\u002Fgo.dev\u002Fdoc\u002Fmodules\u002Flayout",[25],"Go Modules: Organizing a Go module",[17,43,44,45],{},"Effective Go 也强调包名应该简短、清晰、有意义，调用方会通过包名使用导出的标识符。",[21,46,49],{"href":47,"rel":48},"https:\u002F\u002Fgo.dev\u002Fdoc\u002Feffective_go#package-names",[25],"Effective Go: Package names",[17,51,52],{},"所以 Go 项目里的 DDD，不应该追求“目录看起来像经典 DDD”，而应该追求三件事：",[54,55,56,60,63],"ul",{},[57,58,59],"li",{},"业务边界清楚。",[57,61,62],{},"依赖方向清楚。",[57,64,65],{},"Go 代码自然。",[67,68,70],"h2",{"id":69},"_1-单模块项目的推荐结构","1. 单模块项目的推荐结构",[17,72,73],{},"如果协同办公 IM 还处在模块化单体阶段，可以采用单 Go module：",[75,76,82],"pre",{"className":77,"code":79,"language":80,"meta":81},[78],"language-text","im-platform\n├── go.mod\n├── cmd\n│   ├── api\n│   │   └── main.go\n│   └── worker\n│       └── main.go\n├── internal\n│   ├── identity\n│   ├── organization\n│   ├── relationship\n│   ├── group\n│   ├── messaging\n│   ├── notification\n│   ├── openplatform\n│   ├── search\n│   └── governance\n└── pkg\n    ├── errors\n    ├── logger\n    └── clock\n","text","",[83,84,79],"code",{"__ignoreMap":81},[17,86,87,90,91,94,95,98],{},[83,88,89],{},"cmd"," 放启动入口。",[83,92,93],{},"internal"," 放业务上下文，防止被仓库外部 import。",[83,96,97],{},"pkg"," 只放真正通用、无业务含义的技术包。",[17,100,101,102,105,106,109,110,109,113,116],{},"注意，不要把所有东西都放进 ",[83,103,104],{},"pkg\u002Fcommon","。如果一个包里出现 ",[83,107,108],{},"UserDTO","、",[83,111,112],{},"MessageType",[83,114,115],{},"GroupRole"," 这种业务概念，它大概率不应该在公共包里。",[17,118,119],{},"业务模型应该属于某个明确上下文。",[67,121,123],{"id":122},"_2-单个上下文内部怎么组织","2. 单个上下文内部怎么组织",[17,125,126,127,130],{},"以 ",[83,128,129],{},"messaging"," 为例，可以这样组织：",[75,132,135],{"className":133,"code":134,"language":80,"meta":81},[78],"internal\u002Fmessaging\n├── domain\n│   ├── message.go\n│   ├── message_id.go\n│   ├── content.go\n│   ├── receipt.go\n│   ├── event.go\n│   └── repository.go\n├── application\n│   ├── send_message.go\n│   ├── recall_message.go\n│   └── query_message.go\n├── adapter\n│   ├── http\n│   │   └── handler.go\n│   └── event\n│       └── subscriber.go\n└── infrastructure\n    ├── mysql\n    │   └── message_repository.go\n    └── mq\n        └── event_publisher.go\n",[83,136,134],{"__ignoreMap":81},[17,138,139],{},"这不是唯一结构，但它表达了几个规则：",[54,141,142,148,154,160],{},[57,143,144,147],{},[83,145,146],{},"domain"," 放领域模型和领域接口。",[57,149,150,153],{},[83,151,152],{},"application"," 放用例编排。",[57,155,156,159],{},[83,157,158],{},"adapter"," 放输入输出适配，比如 HTTP、gRPC、事件订阅。",[57,161,162,165],{},[83,163,164],{},"infrastructure"," 放数据库、MQ、缓存、外部服务实现。",[17,167,168],{},"依赖方向应该是：",[75,170,173],{"className":171,"code":172,"language":80,"meta":81},[78],"adapter -> application -> domain\ninfrastructure -> domain\napplication -> domain\n",[83,174,172],{"__ignoreMap":81},[17,176,177],{},"领域层不能依赖 MySQL、Redis、Gin、Kafka、HTTP 框架。",[67,179,181],{"id":180},"_3-domain-包里应该有什么","3. Domain 包里应该有什么",[17,183,184,186],{},[83,185,146],{}," 里放的是业务模型，不是数据库模型。",[17,188,189],{},"一个简化的消息模型可以这样写：",[75,191,195],{"className":192,"code":193,"language":194,"meta":81,"style":81},"language-go shiki shiki-themes github-dark","package domain\n\ntype Message struct {\n    id             MessageID\n    conversationID ConversationID\n    senderID       MemberID\n    content        Content\n    status         MessageStatus\n    events         []Event\n}\n\nfunc NewMessage(id MessageID, conversationID ConversationID, senderID MemberID, content Content) (*Message, error) {\n    if content.IsEmpty() {\n        return nil, ErrEmptyContent\n    }\n\n    msg := &Message{\n        id:             id,\n        conversationID: conversationID,\n        senderID:       senderID,\n        content:        content,\n        status:         MessageStatusSent,\n    }\n\n    msg.events = append(msg.events, MessageSent{\n        MessageID:      id,\n        ConversationID: conversationID,\n        SenderID:       senderID,\n    })\n\n    return msg, nil\n}\n\nfunc (m *Message) Recall(operator MemberID) error {\n    if m.senderID != operator {\n        return ErrNoPermission\n    }\n    if m.status == MessageStatusRecalled {\n        return ErrMessageAlreadyRecalled\n    }\n\n    m.status = MessageStatusRecalled\n    m.events = append(m.events, MessageRecalled{MessageID: m.id})\n    return nil\n}\n\nfunc (m *Message) PullEvents() []Event {\n    events := m.events\n    m.events = nil\n    return events\n}\n","go",[83,196,197,205,212,218,224,230,236,242,248,254,260,265,271,277,283,289,294,300,306,312,318,324,330,335,340,346,352,358,364,370,375,381,386,391,397,403,409,414,420,426,431,436,442,448,454,459,464,470,476,482,488],{"__ignoreMap":81},[198,199,202],"span",{"class":200,"line":201},"line",1,[198,203,204],{},"package domain\n",[198,206,208],{"class":200,"line":207},2,[198,209,211],{"emptyLinePlaceholder":210},true,"\n",[198,213,215],{"class":200,"line":214},3,[198,216,217],{},"type Message struct {\n",[198,219,221],{"class":200,"line":220},4,[198,222,223],{},"    id             MessageID\n",[198,225,227],{"class":200,"line":226},5,[198,228,229],{},"    conversationID ConversationID\n",[198,231,233],{"class":200,"line":232},6,[198,234,235],{},"    senderID       MemberID\n",[198,237,239],{"class":200,"line":238},7,[198,240,241],{},"    content        Content\n",[198,243,245],{"class":200,"line":244},8,[198,246,247],{},"    status         MessageStatus\n",[198,249,251],{"class":200,"line":250},9,[198,252,253],{},"    events         []Event\n",[198,255,257],{"class":200,"line":256},10,[198,258,259],{},"}\n",[198,261,263],{"class":200,"line":262},11,[198,264,211],{"emptyLinePlaceholder":210},[198,266,268],{"class":200,"line":267},12,[198,269,270],{},"func NewMessage(id MessageID, conversationID ConversationID, senderID MemberID, content Content) (*Message, error) {\n",[198,272,274],{"class":200,"line":273},13,[198,275,276],{},"    if content.IsEmpty() {\n",[198,278,280],{"class":200,"line":279},14,[198,281,282],{},"        return nil, ErrEmptyContent\n",[198,284,286],{"class":200,"line":285},15,[198,287,288],{},"    }\n",[198,290,292],{"class":200,"line":291},16,[198,293,211],{"emptyLinePlaceholder":210},[198,295,297],{"class":200,"line":296},17,[198,298,299],{},"    msg := &Message{\n",[198,301,303],{"class":200,"line":302},18,[198,304,305],{},"        id:             id,\n",[198,307,309],{"class":200,"line":308},19,[198,310,311],{},"        conversationID: conversationID,\n",[198,313,315],{"class":200,"line":314},20,[198,316,317],{},"        senderID:       senderID,\n",[198,319,321],{"class":200,"line":320},21,[198,322,323],{},"        content:        content,\n",[198,325,327],{"class":200,"line":326},22,[198,328,329],{},"        status:         MessageStatusSent,\n",[198,331,333],{"class":200,"line":332},23,[198,334,288],{},[198,336,338],{"class":200,"line":337},24,[198,339,211],{"emptyLinePlaceholder":210},[198,341,343],{"class":200,"line":342},25,[198,344,345],{},"    msg.events = append(msg.events, MessageSent{\n",[198,347,349],{"class":200,"line":348},26,[198,350,351],{},"        MessageID:      id,\n",[198,353,355],{"class":200,"line":354},27,[198,356,357],{},"        ConversationID: conversationID,\n",[198,359,361],{"class":200,"line":360},28,[198,362,363],{},"        SenderID:       senderID,\n",[198,365,367],{"class":200,"line":366},29,[198,368,369],{},"    })\n",[198,371,373],{"class":200,"line":372},30,[198,374,211],{"emptyLinePlaceholder":210},[198,376,378],{"class":200,"line":377},31,[198,379,380],{},"    return msg, nil\n",[198,382,384],{"class":200,"line":383},32,[198,385,259],{},[198,387,389],{"class":200,"line":388},33,[198,390,211],{"emptyLinePlaceholder":210},[198,392,394],{"class":200,"line":393},34,[198,395,396],{},"func (m *Message) Recall(operator MemberID) error {\n",[198,398,400],{"class":200,"line":399},35,[198,401,402],{},"    if m.senderID != operator {\n",[198,404,406],{"class":200,"line":405},36,[198,407,408],{},"        return ErrNoPermission\n",[198,410,412],{"class":200,"line":411},37,[198,413,288],{},[198,415,417],{"class":200,"line":416},38,[198,418,419],{},"    if m.status == MessageStatusRecalled {\n",[198,421,423],{"class":200,"line":422},39,[198,424,425],{},"        return ErrMessageAlreadyRecalled\n",[198,427,429],{"class":200,"line":428},40,[198,430,288],{},[198,432,434],{"class":200,"line":433},41,[198,435,211],{"emptyLinePlaceholder":210},[198,437,439],{"class":200,"line":438},42,[198,440,441],{},"    m.status = MessageStatusRecalled\n",[198,443,445],{"class":200,"line":444},43,[198,446,447],{},"    m.events = append(m.events, MessageRecalled{MessageID: m.id})\n",[198,449,451],{"class":200,"line":450},44,[198,452,453],{},"    return nil\n",[198,455,457],{"class":200,"line":456},45,[198,458,259],{},[198,460,462],{"class":200,"line":461},46,[198,463,211],{"emptyLinePlaceholder":210},[198,465,467],{"class":200,"line":466},47,[198,468,469],{},"func (m *Message) PullEvents() []Event {\n",[198,471,473],{"class":200,"line":472},48,[198,474,475],{},"    events := m.events\n",[198,477,479],{"class":200,"line":478},49,[198,480,481],{},"    m.events = nil\n",[198,483,485],{"class":200,"line":484},50,[198,486,487],{},"    return events\n",[198,489,491],{"class":200,"line":490},51,[198,492,259],{},[17,494,495],{},"这里没有 ORM 标签，没有 JSON 标签，也没有数据库事务。领域模型只表达消息业务规则。",[67,497,499],{"id":498},"_4-repository-接口放哪里","4. Repository 接口放哪里",[17,501,502,503,505,506,508,509,511,512,514],{},"Repository 接口可以放在 ",[83,504,146],{},"，也可以放在 ",[83,507,152],{},"。我更倾向于：如果接口以聚合为中心，就放在 ",[83,510,146],{},"；如果接口只是某个用例需要的查询端口，就放在 ",[83,513,152],{},"。",[17,516,517],{},"例如聚合仓储：",[75,519,521],{"className":192,"code":520,"language":194,"meta":81,"style":81},"package domain\n\ntype MessageRepository interface {\n    Get(ctx context.Context, id MessageID) (*Message, error)\n    Save(ctx context.Context, message *Message) error\n}\n",[83,522,523,527,531,536,541,546],{"__ignoreMap":81},[198,524,525],{"class":200,"line":201},[198,526,204],{},[198,528,529],{"class":200,"line":207},[198,530,211],{"emptyLinePlaceholder":210},[198,532,533],{"class":200,"line":214},[198,534,535],{},"type MessageRepository interface {\n",[198,537,538],{"class":200,"line":220},[198,539,540],{},"    Get(ctx context.Context, id MessageID) (*Message, error)\n",[198,542,543],{"class":200,"line":226},[198,544,545],{},"    Save(ctx context.Context, message *Message) error\n",[198,547,548],{"class":200,"line":232},[198,549,259],{},[17,551,552],{},"MySQL 实现放到基础设施层：",[75,554,556],{"className":192,"code":555,"language":194,"meta":81,"style":81},"package mysql\n\ntype MessageRepository struct {\n    db *sql.DB\n}\n\nfunc (r *MessageRepository) Get(ctx context.Context, id domain.MessageID) (*domain.Message, error) {\n    \u002F\u002F 查询数据库并组装领域模型\n}\n\nfunc (r *MessageRepository) Save(ctx context.Context, message *domain.Message) error {\n    \u002F\u002F 将领域模型持久化\n}\n",[83,557,558,563,567,572,577,581,585,590,595,599,603,608,613],{"__ignoreMap":81},[198,559,560],{"class":200,"line":201},[198,561,562],{},"package mysql\n",[198,564,565],{"class":200,"line":207},[198,566,211],{"emptyLinePlaceholder":210},[198,568,569],{"class":200,"line":214},[198,570,571],{},"type MessageRepository struct {\n",[198,573,574],{"class":200,"line":220},[198,575,576],{},"    db *sql.DB\n",[198,578,579],{"class":200,"line":226},[198,580,259],{},[198,582,583],{"class":200,"line":232},[198,584,211],{"emptyLinePlaceholder":210},[198,586,587],{"class":200,"line":238},[198,588,589],{},"func (r *MessageRepository) Get(ctx context.Context, id domain.MessageID) (*domain.Message, error) {\n",[198,591,592],{"class":200,"line":244},[198,593,594],{},"    \u002F\u002F 查询数据库并组装领域模型\n",[198,596,597],{"class":200,"line":250},[198,598,259],{},[198,600,601],{"class":200,"line":256},[198,602,211],{"emptyLinePlaceholder":210},[198,604,605],{"class":200,"line":262},[198,606,607],{},"func (r *MessageRepository) Save(ctx context.Context, message *domain.Message) error {\n",[198,609,610],{"class":200,"line":267},[198,611,612],{},"    \u002F\u002F 将领域模型持久化\n",[198,614,615],{"class":200,"line":273},[198,616,259],{},[17,618,619],{},"接口在内层，实现往外放。这是依赖倒置。应用服务依赖接口，启动时由 wire、fx、手写构造函数等方式注入实现。",[67,621,623],{"id":622},"_5-application-service-怎么写","5. Application Service 怎么写",[17,625,626],{},"应用服务负责用例编排。",[17,628,629],{},"例如发送消息：",[75,631,633],{"className":192,"code":632,"language":194,"meta":81,"style":81},"package application\n\ntype SendMessageHandler struct {\n    messages      domain.MessageRepository\n    conversations ConversationPort\n    policies      GovernancePort\n    ids           IDGenerator\n    outbox        Outbox\n}\n\nfunc (h *SendMessageHandler) Handle(ctx context.Context, cmd SendMessageCommand) (domain.MessageID, error) {\n    allowed, err := h.conversations.CanSend(ctx, cmd.ConversationID, cmd.SenderID)\n    if err != nil {\n        return \"\", err\n    }\n    if !allowed {\n        return \"\", ErrCannotSendMessage\n    }\n\n    if err := h.policies.CheckMessage(ctx, cmd.SenderID, cmd.Content); err != nil {\n        return \"\", err\n    }\n\n    msg, err := domain.NewMessage(h.ids.NextMessageID(), cmd.ConversationID, cmd.SenderID, cmd.Content)\n    if err != nil {\n        return \"\", err\n    }\n\n    if err := h.messages.Save(ctx, msg); err != nil {\n        return \"\", err\n    }\n\n    if err := h.outbox.Save(ctx, msg.PullEvents()); err != nil {\n        return \"\", err\n    }\n\n    return msg.ID(), nil\n}\n",[83,634,635,640,644,649,654,659,664,669,674,678,682,687,692,697,702,706,711,716,720,724,729,733,737,741,746,750,754,758,762,767,771,775,779,784,788,792,796,801],{"__ignoreMap":81},[198,636,637],{"class":200,"line":201},[198,638,639],{},"package application\n",[198,641,642],{"class":200,"line":207},[198,643,211],{"emptyLinePlaceholder":210},[198,645,646],{"class":200,"line":214},[198,647,648],{},"type SendMessageHandler struct {\n",[198,650,651],{"class":200,"line":220},[198,652,653],{},"    messages      domain.MessageRepository\n",[198,655,656],{"class":200,"line":226},[198,657,658],{},"    conversations ConversationPort\n",[198,660,661],{"class":200,"line":232},[198,662,663],{},"    policies      GovernancePort\n",[198,665,666],{"class":200,"line":238},[198,667,668],{},"    ids           IDGenerator\n",[198,670,671],{"class":200,"line":244},[198,672,673],{},"    outbox        Outbox\n",[198,675,676],{"class":200,"line":250},[198,677,259],{},[198,679,680],{"class":200,"line":256},[198,681,211],{"emptyLinePlaceholder":210},[198,683,684],{"class":200,"line":262},[198,685,686],{},"func (h *SendMessageHandler) Handle(ctx context.Context, cmd SendMessageCommand) (domain.MessageID, error) {\n",[198,688,689],{"class":200,"line":267},[198,690,691],{},"    allowed, err := h.conversations.CanSend(ctx, cmd.ConversationID, cmd.SenderID)\n",[198,693,694],{"class":200,"line":273},[198,695,696],{},"    if err != nil {\n",[198,698,699],{"class":200,"line":279},[198,700,701],{},"        return \"\", err\n",[198,703,704],{"class":200,"line":285},[198,705,288],{},[198,707,708],{"class":200,"line":291},[198,709,710],{},"    if !allowed {\n",[198,712,713],{"class":200,"line":296},[198,714,715],{},"        return \"\", ErrCannotSendMessage\n",[198,717,718],{"class":200,"line":302},[198,719,288],{},[198,721,722],{"class":200,"line":308},[198,723,211],{"emptyLinePlaceholder":210},[198,725,726],{"class":200,"line":314},[198,727,728],{},"    if err := h.policies.CheckMessage(ctx, cmd.SenderID, cmd.Content); err != nil {\n",[198,730,731],{"class":200,"line":320},[198,732,701],{},[198,734,735],{"class":200,"line":326},[198,736,288],{},[198,738,739],{"class":200,"line":332},[198,740,211],{"emptyLinePlaceholder":210},[198,742,743],{"class":200,"line":337},[198,744,745],{},"    msg, err := domain.NewMessage(h.ids.NextMessageID(), cmd.ConversationID, cmd.SenderID, cmd.Content)\n",[198,747,748],{"class":200,"line":342},[198,749,696],{},[198,751,752],{"class":200,"line":348},[198,753,701],{},[198,755,756],{"class":200,"line":354},[198,757,288],{},[198,759,760],{"class":200,"line":360},[198,761,211],{"emptyLinePlaceholder":210},[198,763,764],{"class":200,"line":366},[198,765,766],{},"    if err := h.messages.Save(ctx, msg); err != nil {\n",[198,768,769],{"class":200,"line":372},[198,770,701],{},[198,772,773],{"class":200,"line":377},[198,774,288],{},[198,776,777],{"class":200,"line":383},[198,778,211],{"emptyLinePlaceholder":210},[198,780,781],{"class":200,"line":388},[198,782,783],{},"    if err := h.outbox.Save(ctx, msg.PullEvents()); err != nil {\n",[198,785,786],{"class":200,"line":393},[198,787,701],{},[198,789,790],{"class":200,"line":399},[198,791,288],{},[198,793,794],{"class":200,"line":405},[198,795,211],{"emptyLinePlaceholder":210},[198,797,798],{"class":200,"line":411},[198,799,800],{},"    return msg.ID(), nil\n",[198,802,803],{"class":200,"line":416},[198,804,259],{},[17,806,807],{},"这个应用服务做了编排，但没有把核心业务规则都写死在自己里面。",[17,809,810],{},"发送权限来自 ConversationPort，风控来自 GovernancePort，消息不变量由 Message 聚合自己维护，事件通过 outbox 落地。",[67,812,814],{"id":813},"_6-adapter-不要反向污染领域","6. Adapter 不要反向污染领域",[17,816,817],{},"HTTP handler、gRPC handler、MQ subscriber 都属于 adapter。",[17,819,820],{},"它们应该把外部协议转换成应用命令：",[75,822,824],{"className":192,"code":823,"language":194,"meta":81,"style":81},"package http\n\nfunc (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) {\n    var req SendMessageRequest\n    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n        writeError(w, err)\n        return\n    }\n\n    id, err := h.sender.Handle(r.Context(), application.SendMessageCommand{\n        ConversationID: domain.ConversationID(req.ConversationID),\n        SenderID:       domain.MemberID(req.SenderID),\n        Content:        domain.NewTextContent(req.Text),\n    })\n    if err != nil {\n        writeError(w, err)\n        return\n    }\n\n    writeJSON(w, SendMessageResponse{MessageID: string(id)})\n}\n",[83,825,826,831,835,840,845,850,855,860,864,868,873,878,883,888,892,896,900,904,908,912,917],{"__ignoreMap":81},[198,827,828],{"class":200,"line":201},[198,829,830],{},"package http\n",[198,832,833],{"class":200,"line":207},[198,834,211],{"emptyLinePlaceholder":210},[198,836,837],{"class":200,"line":214},[198,838,839],{},"func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) {\n",[198,841,842],{"class":200,"line":220},[198,843,844],{},"    var req SendMessageRequest\n",[198,846,847],{"class":200,"line":226},[198,848,849],{},"    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {\n",[198,851,852],{"class":200,"line":232},[198,853,854],{},"        writeError(w, err)\n",[198,856,857],{"class":200,"line":238},[198,858,859],{},"        return\n",[198,861,862],{"class":200,"line":244},[198,863,288],{},[198,865,866],{"class":200,"line":250},[198,867,211],{"emptyLinePlaceholder":210},[198,869,870],{"class":200,"line":256},[198,871,872],{},"    id, err := h.sender.Handle(r.Context(), application.SendMessageCommand{\n",[198,874,875],{"class":200,"line":262},[198,876,877],{},"        ConversationID: domain.ConversationID(req.ConversationID),\n",[198,879,880],{"class":200,"line":267},[198,881,882],{},"        SenderID:       domain.MemberID(req.SenderID),\n",[198,884,885],{"class":200,"line":273},[198,886,887],{},"        Content:        domain.NewTextContent(req.Text),\n",[198,889,890],{"class":200,"line":279},[198,891,369],{},[198,893,894],{"class":200,"line":285},[198,895,696],{},[198,897,898],{"class":200,"line":291},[198,899,854],{},[198,901,902],{"class":200,"line":296},[198,903,859],{},[198,905,906],{"class":200,"line":302},[198,907,288],{},[198,909,910],{"class":200,"line":308},[198,911,211],{"emptyLinePlaceholder":210},[198,913,914],{"class":200,"line":314},[198,915,916],{},"    writeJSON(w, SendMessageResponse{MessageID: string(id)})\n",[198,918,919],{"class":200,"line":320},[198,920,259],{},[17,922,923,924,927],{},"注意不要把 ",[83,925,926],{},"http.Request","、JSON DTO、框架 context 传进领域模型。领域层不应该知道请求来自 HTTP、gRPC 还是 MQ。",[67,929,931],{"id":930},"_7-monorepo-下怎么组织多个服务","7. Monorepo 下怎么组织多个服务",[17,933,934],{},"如果系统已经拆出多个服务，但仍放在一个仓库，可以这样组织：",[75,936,939],{"className":937,"code":938,"language":80,"meta":81},[78],"im-platform\n├── go.work\n├── services\n│   ├── im-api\n│   │   ├── go.mod\n│   │   └── cmd\n│   ├── open-platform-api\n│   │   ├── go.mod\n│   │   └── cmd\n│   └── search-worker\n│       ├── go.mod\n│       └── cmd\n├── domains\n│   ├── messaging\n│   ├── organization\n│   ├── group\n│   └── notification\n└── libs\n    ├── observability\n    ├── config\n    └── errors\n",[83,940,938],{"__ignoreMap":81},[17,942,943,946,947,950],{},[83,944,945],{},"go.work"," 可以帮助本地同时开发多个 module。每个服务有自己的 ",[83,948,949],{},"go.mod","，共享领域包时要非常谨慎。",[17,952,953,954],{},"一个重要原则是：",[955,956,957],"strong",{},"不要让多个服务共享可变领域模型。",[17,959,960,961,964],{},"如果 Messaging 服务和 Open Platform 服务都 import 同一个 ",[83,962,963],{},"domains\u002Fmessaging\u002Fdomain.Message","，短期看减少重复，长期可能造成强耦合。内部领域模型一改，外部服务也被迫改。",[17,966,967],{},"更稳妥的方式是：",[54,969,970,973,976,979],{},[57,971,972],{},"服务内部拥有自己的领域模型。",[57,974,975],{},"跨服务使用 API DTO、事件 schema 或 client 包。",[57,977,978],{},"共享包只放稳定协议或无业务状态的工具。",[57,980,981],{},"不把数据库模型作为跨服务共享模型。",[67,983,985],{"id":984},"_8-单模块和-monorepo-的取舍","8. 单模块和 monorepo 的取舍",[17,987,988],{},"单模块适合：",[54,990,991,994,997,1000,1003],{},[57,992,993],{},"系统还在快速探索。",[57,995,996],{},"团队规模较小。",[57,998,999],{},"领域边界还在调整。",[57,1001,1002],{},"发布节奏基本一致。",[57,1004,1005],{},"运维能力有限。",[17,1007,1008],{},"monorepo 多服务适合：",[54,1010,1011,1014,1017,1020,1023],{},[57,1012,1013],{},"领域边界较稳定。",[57,1015,1016],{},"服务需要独立发布。",[57,1018,1019],{},"团队需要共享工具链。",[57,1021,1022],{},"跨服务重构频繁。",[57,1024,1025],{},"CI 能按变更范围构建和测试。",[17,1027,1028],{},"不要为了“看起来架构先进”提前上 monorepo 多服务。也不要在业务已经明显分化后继续把所有代码塞进一个巨大 module。",[17,1030,1031],{},"工程结构要服务业务演进，而不是服务架构审美。",[67,1033,1035],{"id":1034},"_9-go-里实践-ddd-的几个禁忌","9. Go 里实践 DDD 的几个禁忌",[17,1037,1038,1041],{},[955,1039,1040],{},"不要过度抽象接口。"," Go 里接口应该由使用方定义，而不是每个 struct 都先配一个 interface。",[17,1043,1044,1047,1048,109,1051,109,1054,1057],{},[955,1045,1046],{},"不要把所有包都叫 service。"," ",[83,1049,1050],{},"identity\u002Fservice",[83,1052,1053],{},"messaging\u002Fservice",[83,1055,1056],{},"group\u002Fservice"," 最后还是会变成 service 泥球。用业务含义命名包。",[17,1059,1060,1063],{},[955,1061,1062],{},"不要让 domain 依赖 ORM。"," ORM tag、数据库连接、事务对象都不应该进入领域模型。",[17,1065,1066,1069],{},[955,1067,1068],{},"不要在 pkg\u002Fcommon 放业务对象。"," 一旦业务对象进入 common，边界就开始崩。",[17,1071,1072,1075],{},[955,1073,1074],{},"不要追求纯洁到无法落地。"," 有些查询场景可以走 read model，不必为了“聚合纯粹”绕很远。",[17,1077,1078,1081,1082,109,1085,1088],{},[955,1079,1080],{},"不要把 Java 分层照搬过来。"," Go 不需要到处 ",[83,1083,1084],{},"IUserRepository",[83,1086,1087],{},"UserRepositoryImpl","。包名和接口名要符合 Go 习惯。",[67,1090,1092],{"id":1091},"_10-一个可落地的起点","10. 一个可落地的起点",[17,1094,1095],{},"如果现在要给协同办公 IM 起一个 Go DDD 项目，我会从这个结构开始：",[75,1097,1100],{"className":1098,"code":1099,"language":80,"meta":81},[78],"cmd\u002Fapi\ncmd\u002Fworker\ninternal\u002Fidentity\ninternal\u002Forganization\ninternal\u002Frelationship\ninternal\u002Fgroup\ninternal\u002Fmessaging\ninternal\u002Fnotification\ninternal\u002Fopenplatform\ninternal\u002Fsearch\ninternal\u002Fgovernance\npkg\u002Ferrors\npkg\u002Flogger\npkg\u002Fclock\n",[83,1101,1099],{"__ignoreMap":81},[17,1103,1104],{},"每个业务上下文内部先保持四层：",[75,1106,1109],{"className":1107,"code":1108,"language":80,"meta":81},[78],"domain\napplication\nadapter\ninfrastructure\n",[83,1110,1108],{"__ignoreMap":81},[17,1112,1113],{},"同时建立几条硬规则：",[54,1115,1116,1119,1122,1125,1128,1131],{},[57,1117,1118],{},"外层可以依赖内层，内层不能依赖外层。",[57,1120,1121],{},"跨上下文只能通过接口、DTO 或事件。",[57,1123,1124],{},"数据库表有明确 owner。",[57,1126,1127],{},"领域事件先用 outbox 落地。",[57,1129,1130],{},"搜索、通知、开放平台优先异步化。",[57,1132,1133],{},"公共包只放技术能力，不放业务模型。",[17,1135,1136],{},"这不是唯一正确结构，但它足够简单，也给未来拆服务留下空间。",[17,1138,1139],{},"DDD 的落地，不是把项目一次性改造成某种理想形态，而是在每次业务变化中持续守住模型边界。Go 项目尤其如此：代码要朴素，边界要清楚，抽象要克制。",[17,1141,1142,1143],{},"如果这个系列只能留下一个结论，那就是：",[955,1144,1145],{},"DDD 的目标不是让项目看起来复杂，而是让复杂业务仍然能被人理解、修改和演进。",[17,1147,1148],{},[21,1149,1151],{"href":1150},"\u002Fblog\u002F","返回博客列表",[1153,1154,1155],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":81,"searchDepth":207,"depth":207,"links":1157},[1158,1159,1160,1161,1162,1163,1164,1165,1166,1167],{"id":69,"depth":207,"text":70},{"id":122,"depth":207,"text":123},{"id":180,"depth":207,"text":181},{"id":498,"depth":207,"text":499},{"id":622,"depth":207,"text":623},{"id":813,"depth":207,"text":814},{"id":930,"depth":207,"text":931},{"id":984,"depth":207,"text":985},{"id":1034,"depth":207,"text":1035},{"id":1091,"depth":207,"text":1092},"架构设计","2026-06-30","md",{},"\u002Fblog\u002Fddd-go-project-structure",{"title":5,"description":81},"DDD 实战","blog\u002Fddd-go-project-structure",[1177,1178,1179,1180],"DDD","Go","项目结构","工程实践","sUnzHz9bPoEa6xRBI97dYFnuQfmUHe8MBxDr2y6Ks5Y",1783309725604]