商业产业 Hacker News Agent 2026-08-29T19:28 正文 ok 1882 words

🔶 领域驱动 Agent

英文标题:🔶 Domain-Driven Agents

作者认为,LLM 在新项目和小型项目中能显著提升开发效率,但在依赖复杂、耦合严重且术语混乱的遗留代码库中,输出质量会迅速下降。随着 LLM 大幅降低重构、补测试和跨包修改等“战术性”执行工作的成本,工程师应将主要精力转向理解系统、判断变更方向的“战略性”工作;传统上预留 10% 至 20% 技术预算偿还技术债的方式也因此更可行。作者以 DDD 为基础,通过 `.workflow.json`、上下文词汇表和自动生成的 Context Map,为 Agent 提供统一语言、边界和上下文关系。该方法可让 Agent 以 Issue、子 Agent、PR 和人工审查的流程处理任务,并减少其在遗留系统中对概念归属和架构意图的猜测。
打开原文相关 3 · 机会 1
media1.2 KB media76.8 KB media76.0 KB media92.4 KB media90.0 KB

中文

过去几年里,我在编程乃至更广义的软件工程工作中大量使用 LLM。我多次观察到它能带来怎样的生产力提升,也在越来越多的项目中使用 LLM。它在绿地项目和小型项目中表现良好。但现实是,日常工作需要我们将 Agent 引入遗留代码库:那里有庞大的依赖树、强耦合,以及塞满各种来不及处理事项的技术债积压。我们很快就会发现,LLM 能交付的工作质量会急剧下降。

这种失败有一种特定形态。在一个绿地仓库中要求增加“职位邀请状态”字段,模型会给你一个字段;但在一个已经持续交付四年的系统中提出同样要求,模型会为一个早已以三种不同拼写存在的概念,再发明出第四种拼写,因为代码库本身从未决定哪一种才是真的。它会在直接调用就足够的地方编写适配器,或者在适配器本来就是关键所在的地方直接调用。每一种情况,都是系统没有在任何地方回答的问题。模型只能猜测,而且往往猜错。

因此,棕地项目很深,而技术深度只是第一层。其下还有第二层:混乱、缺失的语义,以及没有可用于消解它们的共享语言。模型正是跌入了这一层。需要升级的不是模型。代码尚未准备好,而“准备就绪”是我们可以逐步构建的能力:一点一点地构建。下面让我展示我是如何做的。

现在比以前容易#

在软件工程早期,只有一个概念:技术债。这是我们作为开发者试图达成目标时的自然结果。我们无法为未来会改变当前代码视角的业务决策提前做好准备。我们必须交付,而且必须快速交付,因此需要付出一些权衡的代价。结果便是,代码坏味道越来越多。通常的应对方式,是将一部分工程预算用于清理:划出技术预算的 10% 至 20% 来偿还技术债。理论上如此……下个季度再说……

五分之一的预算,是为决定应该改变什么,再将其敲进代码所付出的代价,而这两部分从来就不是同样的价格。做决定的成本基本没有变化;把内容敲出来的成本却大幅下降。LLM 可以完成清理工作中机械性的那一半——提取模块、跨两个包的重构、补充更多测试覆盖——其成本已完全不同于 2020 年。偿还技术债仍然需要时间,但所需时间显著减少,而留给我的则是做决定的部分。

战略性与战术性#

我将工作一分为二,并借用 John Ousterhout 在《A Philosophy of Software Design》中的词汇,同时坦率承认我对它们做了一些引申。他用战术性和战略性描述编程时可以采取的两种态度:战术式编程是现在先把东西做出来,战略式编程则是在推进过程中持续投资设计。我将这对概念用于划分创作职责,因为前文所说的经济性正是沿着这条线发生变化。战略性工作是做决定:阅读系统,弄清楚必须改变什么、为什么改变,以及这种改变是否真的服务于功能需求。战术性工作则是把这些决定落实到文件中。前者需要你在脑中掌握整个系统;后者已经变得廉价。

我是如何做的#

在第一类工作中,我深度参与;在第二类工作中,我更像审查者而不是实现者。在第一条路径上,我以更通用的方式分析代码库,评估需要实现的变更,以及这些变更是否与我想交付的功能保持一致。这些方法的产出,是我在每个仓库中创建的 GitHub Issue。

这些 Issue 随后由我的 AI 系统基于 skills 和子 Agent 来处理。skill 是一套书面流程:当任务与其匹配时,模型会加载一个 Markdown 指令文件,因此“处理一个 Issue”或“重新生成 Context Map”等工作每次都以相同方式运行,而不是取决于我当天早晨恰好如何措辞。子 Agent 则是一个独立的模型会话,拥有全新的上下文和自己狭窄的职责范围(实现、进行安全审查、依据规格审查);它只回传结果,而不会把完整对话记录倾倒进我的上下文中。

当它们完成实现后,PR 就已经准备好进入审查。我会进行审查,接受变更或要求进一步改进。我可以增量地完成这件事,同时关注测试覆盖率以及哪些对象会受到破坏:在一项变更合入之前,我需要知道系统中还有哪些部分消费了我正在修改的内容,以及它们能否承受这项变更。此时,作为软件工程师,我负责协调、规划,并为改进创建路径。但在这个阶段,我不再需要亲自实现所有内容,时间便被节省下来。

DDD 作为基础#

这就留下了战略性的那一半,而它的价值完全取决于书写它所使用的语言。这正是 DDD 发挥作用的地方。

对于那些一年后我仍希望能够修改的软件,DDD 一直是我的选择之一。Eric Evans 提出的这套方法,为我们缩小业务与技术之间的沟通鸿沟提供了途径。以通用语言和限界上下文为基础的领域驱动设计,会将业务需求直接转化到技术层面。双方使用同一种语言交流。有了 Agent 参与后,这种连接更加重要:它既是我们向模型表达需求的方式,也是我们解读模型推理结果的方式。因此,我如此重度地依赖它。

我是如何做的#

我拥有的每个仓库根目录中都包含一个 .workflow.json。这是我自己的清单文件,是一个仓库向我的工具链说明自身属性的地方:它包含哪些语言、Agent 应该优先阅读哪些目录、其中的工作在可以发布前必须通过哪些检查。它有一个区块专门描述领域,而声明该区块就是一个仓库所需的全部注册操作。不存在第二个会逐渐失去同步的注册表。

该区块声明项目名称、其限界上下文、每个上下文词汇表的位置、其子域类型,以及与相邻上下文之间的每一条边。下面的示例来自我的项目 job-offer-box:这是一个职位申请跟踪器,由两个仓库组成——我在 hyperion 项目下维护的 Rust 后端,以及一个 Web 前端。以下是前端的清单,已裁剪为只保留一条边:

{
  "domain": {
    "project": "job-offer-box",
    "contexts": [
      {
        "name": "job-box-web",
        "docs": "CONTEXT.md",
        "subdomain": "supporting",
        "edges": [
          {
            "to": "hyperion/job-offer-backend",
            "direction": "outbound",
            "pattern": "unclassified",
            "owner": "supplier",
            "shape": "codegen from the backend's document (scripts/generate-api.ts:12) ... conformist on write (src/lib/api/jobs.ts:37), ACL on read (src/lib/api/adapters/offer.ts:50)",
            "note": "conformist on write and an anticorruption layer on read; two patterns hold at once, so neither name alone is true"
          }
        ]
      }
    ]
  }
}

按顺序阅读它。to 是地址:另一端是哪个上下文。direction 说明谁调用谁;Web 仓库调用后端,因此为 outbound(后端自身的清单会将同一条边声明为 inbound)。owner 说明如果两边发生分歧,谁的模型优先:这里是后端,因此为 supplier。pattern 是关系本身,从一个受限词汇表中选择;这里是 unclassified,因为 Web 仓库同时做了两件不同的事。它在写入时原样接受后端的数据形状,而在读取时将其转换为自己的数据形状。note 对此作出说明:单一标签会对其中一种情况正确,却对另一种情况错误。

每个上下文旁边都有一个 CONTEXT.md,作为持续演进的词汇表,记录每个术语的精确定义,以及被刻意拒绝的同义词。每个上下文有两个文件,二者都由拥有代码的仓库负责维护。它们之上没有任何内容需要人工编写:Context Map——展示整个产品组合中所有上下文及其相互边关系的那份文档——是派生出来的。一个生成器,即一个遍历本地所有仓库的脚本,会汇总 domain 区块,并将其输出为单个 CONTEXT-MAP.md。这张地图是可丢弃、可重新生成的。

回到 job-offer-box。hyperion/job-offer-backend 拥有产品语言。它持久化 Job Offer、Profile、Profile Variant、Resume、Cover Letter,并遵循这样一条规则:当两个上下文都定义同一个术语时,持有持久状态的一方拥有该术语。job-offer-box/job-box-web 只拥有屏幕词汇(View Model、Filter State、Facet Stats),并将其他所有内容标记为 [published];这些内容以从后端 OpenAPI 文档生成的 TypeScript 形式原样到达。这正是 Agent 所需要的精确度。将它指向 Web 仓库后,它便知道在那里重命名 Job Offer 应属于后端的职责,知道读取路径上的适配器是有意存在的,也知道自己被允许创造哪些词汇。有了地图,模型知道自己处在哪个上下文;有了词汇表,它知道该上下文中使用的词语。

双方都要声明,因此分歧可以机械化校验#

每条边都会从双方各声明一次,而这种重复正是关键所在。生成器会交叉检查这些配对,并且会谨慎判断何种情况才算分歧:供应方声明自己的立场(published-language),消费者声明自己的立场(conformist、anticorruption-layer),因此检查采用的是配对表。

我将其作为一项 skill 运行,在三个时机执行:当我修改过清单文件时、当我将一个仓库纳入体系时,以及当我准备修改其他上下文所依赖的内容之前。它报告的每一项分歧都是一个发现项:一条边,以及双方声明无法匹配的一种方式。只需一个标志,该 skill 就会将每项发现作为 DDD Issue 提交到拥有错误一侧的仓库。Issue 会携带一个指纹信息(发现项类别加上两个地址),因此在部分修复后重新运行时,它会更新同一个 Issue,而不是再创建第二个;而不再出现的发现项则会关闭其 Issue。从这里开始,它遵循与本文其他内容相同的主线:一个 Issue、一个 Agent、一个 PR、我的审查。

接下来是什么#

这就是战略层,它已经就位。它确定了一个上下文在哪里结束,以及它如何与邻居交流:即地图的形状。但任何单个上下文的内部,仍然可能只是普通代码,让你能够构建一个毫无意义的对象并将其保存。

在 Context Map 就位且词汇表已定义后,我可以将注意力集中到代码库本身:一次处理一个上下文,将其迁移到由 DDD 原语(值对象、聚合、领域服务及其他)构成的真实领域模型。这个过程会让代码库回答此前模型只能猜测的问题:这个词是什么意思、谁拥有它、这个上下文的边界在哪里。我很快会分享完整系统,以及可直接使用的 skills。

English / 原文

I've been using LLMs heavily in the last years in coding, or more generally, in software engineering. I watched many times what productivity boost I could gain from it, and I used LLMs in more and more of my projects. It works well in greenfield projects, and small ones. The reality is that in day to day work we need to introduce agents into legacy codebases with heavy dependency trees, strong coupling, and a tech debt backlog full of everything we never got to. We quickly notice that the quality of work LLMs can deliver drops sharply.

The failure has a specific shape. Ask for a "job offer status" field in a greenfield repo and you get one. Ask for it in a system that has been shipping for four years and the model invents a fourth spelling of a concept that already exists three times, because the codebase itself never decided which one was real. It writes an adapter where a call was fine, or calls straight through where an adapter was the whole point. Every one of those is a question about the system that the system does not answer anywhere. The model guesses, and often guesses wrong.

So brownfield projects are deep, and technical depth is only the first layer. Underneath sits a second one: confusion, missing meaning, and no shared language to resolve it in. That is the layer the model falls into. The model is not what needs upgrading. The code is not ready , and readiness is something we can build. Incrementally. Piece by piece. Let me show you how I do it.

It is easier than before#

At the beginning of software engineering there was the one and only: tech debt. It's a natural consequence of what we, as devs, are trying to achieve. We're not ready for business decisions from the future shifting our current view of the code. We need to deliver, and deliver fast, paying some tradeoffs. As a consequence, code smell grows bigger and bigger. The usual answer is to spend part of the engineering budget on cleanups: earmark 10-20% of the technology budget for resolving tech debt. In theory... In the next quarter...

A fifth of the budget is the toll on deciding what should change and then typing it out, and those two halves have never had the same price. Deciding stayed about as expensive as it was. Typing it out collapsed. An LLM will do the mechanical half of a cleanup (the extracted module, a refactor across two packages, more test coverage) at a cost that no longer resembles 2020. Paying tech debt still takes time. It takes significantly less of it, and what is left for me is the deciding part.

Strategic vs tactical#

I split the work in two, and I'll borrow the words from John Ousterhout's A Philosophy of Software Design while being honest that I'm bending them. He uses tactical and strategic for two attitudes you can hold while coding: tactical programming is getting-it-working-now, strategic programming is investing in the design as you go. I use the same pair for a split of authorship , because the economics above cut along that line. Strategic work is deciding: reading the system, working out what has to change and why, and whether the change actually serves the feature. Tactical work is carrying that decision into the files. The first is the part that needs the system in your head. The second is the part that got cheap.

What I do#

In the first one I'm fully involved and in the second one I'm rather a reviewer than an implementer. In the first path I analyze the codebase in a more generic way, assessing the changes that need to be implemented and their alignment to the features I want to deliver. The effect of those approaches is GitHub issues I create in each repository.

The issues are then addressed by my AI system based on skills and sub-agents. A skill is a written procedure: a markdown file of instructions the model loads when the task matches it, so "address an issue" or "regenerate the context map" runs the same way every time instead of the way I happened to phrase it that morning. A sub-agent is a separate model session with its own fresh context and its own narrow job (implement, review for security, review against the spec), reporting back a result rather than dumping its whole transcript into mine.

When they are implemented, PRs are ready to jump into. I go through the review sessions, accepting the changes or asking for some improvements. I can do that incrementally, caring about the test coverage and about who breaks: before a change lands I need to know which other parts of the system consume the thing I'm touching, and whether the change is one they can survive. Now, as a software engineer, I coordinate, I plan, and I create a path for the improvements. But at that point I don't need to implement that by myself. The time is saved.

DDD as a fundament#

That leaves the strategic half, and it is worth exactly as much as the language it is written in. This is where DDD comes in.

DDD was always one of my choices for software I could still change a year later. The approach presented by Eric Evans gave us a way to shrink the communication gap between the business and the technical side. Domain-driven design, based on ubiquitous language and bounded contexts, translates what the business needs directly into the technical part. Both sides talk in the same language. With agents in the loop, that link matters even more: it is how we state our needs to the model and how we read its reasoning back. That is why I build on it so heavily.

What I do#

Every repository I own carries a .workflow.json at its root. It is my own manifest, the place a repo tells my tooling what it is: which languages it holds, which directories an agent should read first, which checks have to pass before work in it can ship. One block in it is about the domain, and declaring that block is the only registration a repo needs. There is no second registry to drift out of sync.

The block names the project, its bounded contexts, where each context's glossary lives, its subdomain type, and every edge to a neighbouring context. The example comes from a project of mine, job-offer-box , a job application tracker built as two repositories, a Rust backend I keep under the hyperion project and a web frontend. Here is the frontend's manifest, trimmed to a single edge:

{
  "domain": {
    "project": "job-offer-box",
    "contexts": [
      {
        "name": "job-box-web",
        "docs": "CONTEXT.md",
        "subdomain": "supporting",
        "edges": [
          {
            "to": "hyperion/job-offer-backend",
            "direction": "outbound",
            "pattern": "unclassified",
            "owner": "supplier",
            "shape": "codegen from the backend's document (scripts/generate-api.ts:12) ... conformist on write (src/lib/api/jobs.ts:37), ACL on read (src/lib/api/adapters/offer.ts:50)",
            "note": "conformist on write and an anticorruption layer on read; two patterns hold at once, so neither name alone is true"
          }
        ]
      }
    ]
  }
}

Read it in order. to is the address: which context on the other end. direction says who's calling whom; the web repo calls the backend, so outbound (the backend's own manifest declares the same edge inbound). owner says whose model wins if the two sides ever disagree: the backend's, so supplier. pattern is the relationship itself, picked from a closed vocabulary; here it's unclassified, because the web repo does two different things at once. It accepts the backend's shape as-is when writing and translates it into its own shape when reading. The note spells that out; a single label would be right about one case and wrong about the other.

Beside the manifest sits a CONTEXT.md per context, the living glossary with the precise meaning of every term and the deliberately rejected synonyms. Two files per context, both owned by the repo that owns the code. Nothing above them is authored: the context map (the one document showing every context in the portfolio and every edge between them) is derived. A generator, a script that walks every repo on disk, unions the domain blocks and emits it as a single CONTEXT-MAP.md. The map is disposable and regenerable.

Back to job-offer-box. hyperion/job-offer-backend owns the product language. It persists Job Offer , Profile , Profile Variant , Resume , Cover Letter , under the rule that where two contexts author the same term, the one holding the durable state owns it. job-offer-box/job-box-web owns only the screen vocabulary (View Model , Filter State , Facet Stats) and marks everything else [published], arriving verbatim as generated TypeScript from the backend's OpenAPI document. That is the level of precision an agent needs. Point it at the web repo and it knows that renaming Job Offer there belongs to the backend, that the adapters on the read path exist on purpose, and which words it is allowed to invent. With the map the model knows which context it is in, and with the glossary it knows the words used there.

Both sides declare, so disagreement is mechanical#

Every edge is declared twice, once from each side, and that duplication is the whole point. The generator cross-checks the pairs, and it is careful about what counts as a disagreement: a supplier names its own stance (published-language), a consumer names its own (conformist, anticorruption-layer), so the check is a pairing table.

I run it as a skill, at three moments: when I have touched a manifest, when I am onboarding a repo, and before I change anything another context depends on. Each disagreement it reports is a finding : one edge, one way the two declarations fail to fit. With one flag, the skill files each one as a DDD issue on the repo that owns the wrong side. The issue carries a fingerprint (the kind of finding plus the two addresses), so a re-run after a half-fix updates the same issue instead of opening a second one, and a finding that no longer appears closes its issue. From there it follows the same spine as everything else here: an issue, an agent, a PR, my review.

What comes next#

That is the strategic layer, and it is already in place. It settles where a context ends and how it talks to its neighbours: the shape of the map. The inside of any single context is still ordinary code that lets you build a nonsense object and save it.

With the context map in place and the glossary defined, I can focus on the codebase itself: taking one context at a time and migrating it to a real domain model built from DDD primitives (value objects, aggregates, domain services and others). That process makes the codebase answer the questions the model was guessing at: what this word means, who owns it, where this context stops. I'll share the whole system shortly, with the skills ready to use. Subscribe so you don't miss it.