命令和查询应拆分为写路径与读路径而非单纯结构体,因go无cqrs语言支持;命令结构体仅含必要字段,查询结构体为dto风格,两者均为plain struct且不嵌入运行时依赖。

命令和查询为什么要拆成两个结构体
因为 Go 里没有语言级的 CQRS 支持,硬套模式反而会让代码更难维护。真正该拆的是「写路径」和「读路径」——前者要校验、事务、副作用;后者只关心怎么快、怎么稳地返回数据。
常见错误是把 Command 和 Query 设计成接口,然后一堆实现塞进去,结果 handler 里还要手动类型断言或反射调用。这不是解耦,是给自己加调度层。
- 命令结构体只包含必要字段(如
UserID、Amount),不带任何 getter/setter 方法 - 查询结构体聚焦 DTO 风格(如
UserSummary、OrderListResult),字段名直接对齐前端需要,不复用领域模型 - 两者都该是 plain struct,别嵌套
context.Context或*sql.Tx这类运行时依赖——那些属于 handler 层职责
Handler 层怎么避免“伪 CQRS”
很多项目写了 HandleCreateUserCommand 和 HandleGetUserQuery,但两个 handler 共享同一个 repository 接口,底层还是走同一张表、同一个 ORM 实例。这叫“命名 CQRS”,不是真分离。
关键在数据访问契约是否真正隔离:命令侧写入主库(可能含事件发布),查询侧走独立的只读副本或物化视图。
立即学习“go语言免费学习笔记(深入)”;
- 命令 handler 依赖
UserRepository(带Create、Update方法) - 查询 handler 依赖
UserReader(只有FindByID、Search等只读方法),实现可替换为 SQL 查询、Elasticsearch 或缓存 - 别让
UserReader实现里偷偷调UserRepository—— 这会绕过所有读写分离设计
事件发布时机踩什么坑
CQRS 常搭配事件溯源,但 Go 里过早引入 EventStore 容易失控。最常出问题的是:命令 handler 里刚写完 DB 就发事件,但事务还没提交,下游消费到的是“幻读”数据。
Go 没有像 Spring 的 @TransactionalEventListener 那样的生命周期钩子,得自己控住边界。
- 事件发布必须放在事务成功提交之后,推荐用回调函数注入(如
afterCommit(func(){...})) - 避免在 handler 里直接调
eventbus.Publish(),改用返回[]Event,由外层统一 dispatch - 如果用 Kafka,注意
sync.Producer的重试策略和幂等性开关必须打开,否则重复事件会破坏查询端一致性
查询端缓存和最终一致性怎么拿捏
查不到刚创建的用户?这是 CQRS 最直观的“痛感”。不是要消灭延迟,而是得让人清楚延迟在哪、能接受多久。
Go 服务里最容易被忽略的是:缓存失效没跟命令写入联动,或者用了本地内存缓存(如 sync.Map)导致多实例间不同步。
- 查询端优先用分布式缓存(Redis),key 命名带上聚合根 ID 和版本号,如
user:123:v2 - 命令 handler 提交后,主动触发缓存删除(
redis.Del("user:123*")),而不是等 TTL 过期 - 对强一致性要求高的场景(如支付结果页),不要走 CQRS 查询流,直接查主库 + 加读锁,CQRS 只服务于列表页、后台报表这类容忍秒级延迟的地方
真正麻烦的从来不是怎么拆命令和查询,而是当查询端数据滞后时,你能不能一眼看出是 eventbus 卡了、consumer 挂了,还是缓存 key 设计错了——这些地方没日志、没指标、没 trace ID,就只剩翻日志猜。










