Back to Blog
July 22, 2026

How I Use Domain-Driven Design in AI-Assisted Software Delivery

LLMs default to average architecture. Here is how to force them to write clean abstractions.

By Sachin Kundu | Read on Substack

I have always been a fan of Domain-Driven Design. At the same time, I have found it difficult to apply consistently in real software teams.

DDD introduces a considerable cognitive load. Context maps, glossaries, bounded contexts, aggregates, domain events, and anti-corruption layers are individually understandable, but using them together requires experience and sustained attention. In my professional experience, it was difficult to get an entire team to apply these ideas consistently. Teaching the terminology was possible. Developing the judgement needed to draw sensible boundaries and maintain them as the system evolved was much harder.

In case you have never applied DDD, you should! You could read the book from Eric Evans and others but ofcourse you could just ask claude/codex to explain and distill. That’s what I do these days for all concepts I quickly want to get an overview on.

If you let an LLM write your system without strong guardrails, it will give you exactly what it was trained on: average code. The public repositories models learn from are full of anemic domain models and bloated service classes. These are well-known anti-patterns, yet they are the model’s natural default. An agent left to its own devices will happily create objects that are little more than dumb data containers, shoving the actual business logic into generic managers.

Subscribe now

AI assisted software delivery has made applying DDD easier for me. I don’t need to remember things but have a general idea of what I am driving towards. Architectural judgement is still mine and that is the most importnat part of software engineering anyways. AI can generate code when my carefully designed architecture is in place, and AI helps a lot in thinking through this design process. It has made the process of exploring, documenting, and applying those decisions much less laborious.

What recent research suggests

I am glad to see some recent research activity around this subject which more or less reflects my workflow which I have detailed below.

One relevant paper is Automating Domain-Driven Design: Experience with a Prompting Framework. The researchers divided DDD analysis into five stages: establishing a Ubiquitous Language, simulating Event Storming, identifying bounded contexts, designing aggregates and invariants, and finally mapping the model to a technical architecture.

Conclusion?

It works if you steer it.

The models were effective at extracting terminology, identifying events, organising concepts, and proposing possible bounded contexts. Their performance deteriorated when they moved into aggregate design and technical architecture. Small mistakes made during the earlier stages accumulated, and the later architectural output required substantial human correction.

So an LLM can be a useful thinking partner for requirements, test cases, terminology, and possible domain boundaries. It is much less reliable as the authority that decides those boundaries. Which is no surprise.

The same limitation appears at the code level. The public code on which models have been trained contains a great many anemic domain models and large service classes. Even though these are well-known DDD anti-patterns, they remain common. Without strong guidance, an agent often creates objects that are little more than data containers and places the actual business behaviour in generic services or managers.

The output can look clean and still represent the domain poorly. Good DDD is not the model’s natural default.

A small example to make this concrete

To make the rest of the article concrete, I will use a small community tool library as a running example. Members can reserve and borrow drills, ladders, cameras, and other equipment. It is familiar enough to understand without explanation, but it still contains useful domain distinctions.

A ToolModel describes a type of tool, such as a particular drill model. A ToolItem is one physical drill with its own condition and availability. A Reservation holds a ToolItem for future collection, while a Loan begins only when the member actually collects it. Returning the item does not necessarily make it available again because it may first need an Inspection.

These distinctions are small but important. Losing them produces the kind of superficially clean but confused model one should avoid.

My workflow starts with a conversation

I begin by writing the requirements specification through a long conversation with the AI.

I don’t have or would even want a polished prompt. I explain the problem as I would to a colleague. I describe the workflow, remember exceptions, contradict earlier assumptions, and gradually refine what I actually mean. This is often a long and somewhat messy process.

The AI is valuable because it can keep reorganising that conversation into a more coherent requirements specification. It can point out ambiguities, identify missing cases, and ask questions that make me reconsider the model. The important work is not the production of a document. It is the gradual clarification of my own understanding.

For example, I might start with something as loose as:

Members should be able to reserve a drill and collect it later. A returned drill should probably become available again, except when it is damaged or perhaps every return should be checked first. I am not yet sure whether the reservation is for a type of tool or one specific physical item.

After questioning those ambiguities, the requirement can become much more precise:

AC-LEND-07: A member reserves a specific available ToolItem for a defined collection period. Collection starts a Loan. Returning the ToolItem ends the Loan and places the item UnderInspection. It cannot be reserved again until Maintenance records that the inspection passed.

In that ways talking to AI is similar to writing. Once you start to write your thoughts get clearer. That is why improved voice mode on chatgpt for example is so nice.

Once the requirements are reasonably stable, I move to the glossary.

The glossary sharpens the design

The glossary is probably the most valuable DDD artifact in my workflow.

Naming has always been one of the hardest parts of software design. The challenge is not simply choosing readable class names. It is determining which concepts actually exist, what they mean, where that meaning applies, and which apparently similar concepts should remain separate.

Working through the glossary forces me to examine those distinctions.

A term should have a precise meaning within its bounded context(which you have not written down yet, so this is an iterative process). I also record concepts with which it must not be confused and alternative names that agents should avoid. This matters because LLMs often introduce plausible synonyms without recognising that the terminology was chosen deliberately. A specification may define a PriceQuote, while the generated code gradually shifts toward Estimate, Offer, or PricingProposal. Each substitution may seem harmless, but over time the requirements, code, tests, and documentation begin describing the same concept in different ways.

The glossary therefore serves two purposes. It improves my own understanding of the domain, and it gives the agents a controlled vocabulary.

A small glossary artifact for this example could look like this:

bounded_context: Lending
terms:
  ToolItem:
    definition: A single physical unit that can be reserved and loaned.
    not_to_be_confused_with: [ToolModel]
    aliases_to_avoid: [Tool, Equipment]
  Reservation:
    definition: A temporary allocation of a ToolItem for future collection.
    not_to_be_confused_with: [Loan]
    aliases_to_avoid: [Booking]
  Loan:
    definition: The period during which a member has collected a ToolItem.
    not_to_be_confused_with: [Reservation, Rental]
  UnderInspection:
    definition: The state after return and before Maintenance approves circulation.

In this example, ToolModel and ToolItem are deliberately different. Calling both of them Tool would hide the distinction between a catalogue description and a physical item that can be reserved, damaged, inspected, or taken out of service. Reservation and Loan are also not synonyms: a reservation concerns future availability, while a loan records that a particular item has actually been collected. Those distinctions give the later design something precise to preserve.

The same language can then appear in a behaviour rich domain object rather than being scattered through a generic service.

I want that language to appear consistently in the requirements, code, events, APIs, and BDD scenarios. When the terminology begins to drift, I treat it as a possible modelling problem rather than a cosmetic naming issue.

Designing bounded contexts

After the glossary, I work on the bounded contexts. But again this is iterative. Hand in Hand is more like it.

This is the stage where I find AI useful but least trustworthy. A model will produce bounded-context proposals readily, often with convincing explanations. It may nevertheless create boundaries that are theoretically tidy but operationally impractical. It can split concepts that need to change together or group concepts because they resemble a familiar technical pattern.

I use the proposals as alternatives to consider, not as decisions.

For the tool library, one proposal might place everything inside a single ToolRental context. Another might separate Catalog, Lending, Maintenance, and Billing. Neither proposal is automatically correct. Separating Lending from Maintenance is useful if returning a tool ends the loan but inspection determines whether the physical item becomes available again. Separating ToolModel from ToolItem into different contexts merely because they are different nouns may be artificial if they are maintained by the same people and change together. This is the kind of decision for which the agent can produce plausible alternatives, but cannot know the practical answer.

I look at whether the language remains coherent inside the proposed boundary, which business capability the context owns, and how frequently a change would need to cross several contexts. I also consider coupling and operational complexity. A clean diagram is not useful if ordinary changes require constant coordination across multiple boundaries.

BTW similar concerns were considered when designing good microservices which also lent well to thinking in terms of bounded contexts

Needless to say this is hard to do for humans so don’t expect AI to help much either.

These decisions are not permanent. The contexts will evolve as the product and my understanding of it change. Even so, the initial structure matters because coding agents will reinforce whatever organisation they encounter. If the boundaries are unclear, the generated code will make that ambiguity more expensive and harder to reason with and change.

Making the context map readable by agents

Once I have a reasonable set of bounded contexts, I describe their relationships in a context map.

The usual visual context map is useful for humans, but I do not use an image as the only source of truth. Since coding agents primarily operate on text, I write the map in JSON or YAML and keep it in the repository.

It records what each context owns, which contexts it may communicate with, which events or APIs form those relationships, and which dependencies are forbidden. It can also describe where an anti-corruption layer is required and which external or neighbouring concepts must not leak into the local model.

For a reader, the central relationship is simple:

[Lending] -- ToolReturned --> [Maintenance]

For an agent, I would keep the same relationship in structured form

contexts:
  lending:
    owns: [Reservation, Loan]
    publishes: [ToolReturned]
    forbidden_dependencies: [maintenance.persistence]
  maintenance:
    consumes: [ToolReturned]
    publishes: [InspectionPassed, DamageDetected]

The point of this file is not the YAML syntax. It is that the relationship is explicit: Lending may announce that a tool was returned, but it cannot decide that the tool passed inspection or reach into Maintenance’s database to change that state.

An anti-corruption layer becomes useful when an external maintenance system uses a different model. Suppose its API reports condition codes rather than the domain events used here:

External response could look like

{ "assetId": "T-184", "conditionCode": "A1" }

and the ACL would translate that cleanly

A1 -> InspectionPassed
D4 -> DamageDetected

The Lending and Maintenance models do not need to adopt assetId, conditionCode, or the external system’s status vocabulary. Only the adapter understands both languages.

I then reference this map from AGENTS.md or from the instructions used by architecture and review agents.

This gives the agent something more precise than a general request to “respect the architecture.” It can see that Ordering may publish an event consumed by Billing, for example, but may not import Billing’s persistence model or write directly to its tables.

The structured representation also makes automated checks possible. Some relationships can be enforced through dependency rules, import restrictions, schema validation, or architecture tests rather than being left entirely to review.

Identifying events

I return to the requirements and context map when identifying the events that need to be emitted and consumed.

The AI can help trace a business process and suggest domain events, but I pay close attention to their ownership and meaning. Errors made here spread quickly. A poorly named event introduces terminology drift. An event emitted by the wrong context weakens ownership. A payload that exposes internal models creates coupling that agents will continue to depend on.

In the running example, ToolReturned belongs to Lending because Lending knows when the member has returned the borrowed item. InspectionPassed belongs to Maintenance because only that context decides whether the physical ToolItem is fit to circulate again. An event such as ToolUpdated would be easier to generate, but it would hide both the business meaning and the ownership of the change.

The difference is visible in the event contract itself. A vague technical event might expose persistence details:

{
  "type": "ToolUpdated",
  "toolRowId": 184,
  "statusCode": 4,
  "updatedFields": ["status"]
}

A domain event states what happened without leaking the internal storage model

{
  "type": "ToolReturned",
  "toolItemId": "T-184",
  "loanId": "L-902",
  "returnedAt": "..."
}

Perhaps you are thinking this just sounds like good architecture design 101 and you are right. But something that is easy to do is also easy not to do!

Using DDD to improve BDD

I am also a fan of Behaviour-Driven Development, although my previous experience with it was similar to my experience with DDD: the idea was better than the consistency of its application.

BDD was supposed to connect requirements to executable acceptance tests. In practice, scenarios became stale, step definitions became difficult to maintain, and the requirements gradually separated from the tests. Production code received immediate attention, while the BDD specifications lagged behind.

AI reduces some of this friction. It can generate scenario drafts, maintain bindings, identify missing examples, and update traceability information. More importantly, DDD gives the BDD specifications a clearer semantic foundation.

The glossary provides the language. The bounded contexts clarify where behaviour belongs. Aggregates identify the business invariants that need to be exercised. Domain events clarify the outcomes that cross context boundaries.

In an AI-assisted workflow, BDD is no longer just a nice-to-have documentation tool. It becomes a verifiable contract between human intent and the LLM’s generated code. When an agent writes implementation logic, it will inevitably make micro-decisions about how things should work. If you only give it a loose text prompt, you have no automated way to prove it didn’t completely miss the business point. A BDD scenario, grounded in the DDD glossary, gives the agent an executable boundary. It proves the code does exactly what the domain requires, using the exact vocabulary we agreed upon, rather than hallucinating a plausible-looking alternative.

This helps prevent vague scenarios that technically pass but use the wrong business concepts.

For example, releasing a payment authorisation and issuing a refund may appear similar in casual language, but they represent different behaviours. If the glossary and requirements distinguish them, the BDD specification must preserve that distinction.

The same distinction can be expressed directly in BDD:

@AC-LEND-07
Scenario: A returned tool remains unavailable until inspection
  Given a ToolItem is OnLoan
  When the member returns it
  Then the ToolItem becomes UnderInspection
  And it cannot be reserved
  And ToolReturned is published

The scenario is useful because it forces the requirements, glossary, event name, and observable behaviour to agree on the same concepts.

Reconciling requirements and BDD

I also add reconciliation checks between the requirements and BDD specifications.

Every testable acceptance criterion receives a stable identifier, and every BDD scenario references the criterion or criteria it covers. This allows the system to verify both directions of the relationship. A requirement should lead to at least one executable scenario, and every scenario should lead back to an actual requirement.

The reconciliation check fails when a criterion has no scenario, when a scenario has no corresponding requirement, when a reference points to a nonexistent criterion, or when a scenario contains undefined or pending steps. It also checks that the terminology remains consistent with the glossary.

In this example, the requirement would carry the identifier AC-LEND-07 and the scenario would reference it with the same tag. A reconciliation check can prove that the link exists and that the steps execute.

The automated part can produce a simple report when reconciliation passes or fails.

This proves structural traceability, but it does not prove semantic correctness. A scenario can reference the correct requirement ID while expressing a different business meaning. I therefore use AI review to flag possible mismatches, but I still judge whether the requirement and scenario actually say the same thing.

Using specialised agents

This structure makes parallel agent work much more manageable.

I use an architecture agent to produce an implementation design from the requirements, glossary, context map, and existing architecture. I then use a separate DDD reviewer in an adversarial role.

The reviewer looks for incorrect ownership, anemic models, invented terminology, cross-context dependencies, missing anti-corruption layers, misplaced business rules, and aggregates that do not protect a meaningful invariant.

I also give general code-review agents explicit instructions to look for DDD violations. Unless this is specifically mentioned to the review agents, they miss that the implementation uses the wrong domain concept or places important behaviour in a generic service.

BTW, separation between author and reviewer is important. LLM have a very high regard for their own output! An agent that has already committed to a design is likely to defend or repeat the assumptions behind that design. An independent reviewer is more likely to challenge them.

The roles also receive different instructions. The architecture agent may be told:

Produce an implementation design using the approved requirements, glossary, and context map.
Do not add a bounded context or cross-context dependency.
If you do then you must list it as a proposed architecture change for human approval.

The DDD reviewer receives a deliberately different brief:

Assume the design may be wrong. 
Look for invented terminology, anemic domain objects, misplaced invariants.
Identify direct access to another context's persistence, and events that leak internal models. 
Do not approve a design merely because it is internally consistent.

To show how this adversarial setup works in practice, here is a simulated exchange of what this looks like when an Architecture Agent proposes a design, and the DDD Reviewer pushes back

Architecture Agent:
Proposed Change: Added UpdateToolCondition API in Lending context. 
When a tool is returned damaged, Lending will update the 
conditionCode in the ToolItem table so the reservation system knows it cannot be booked.
DDD Reviewer:
Review Failed.
Misplaced Responsibility: The Lending context does not own tool condition or inspection. 
It only owns the Loan and Reservation lifecycle.
Persistence Leak: Lending is directly accessing the internal state (conditionCode) of ToolItem, 
which belongs to Maintenance.
Required Fix: Lending must publish a ToolReturned event. 
Maintenance must consume this event, perform its own inspection logic, and publish a DamageDetected event.

This kind of interaction is incredibly valuable. If a single agent was both writing and reviewing, it would likely justify its own shortcut because direct database updates are ‘more efficient’ or ‘easier to implement.’ The adversarial reviewer forces the system to respect the domain boundaries

Another clear benefit of splits across bounded contexts is that working with parallel implementation agents becomes very elegant.

With the tool library split into Lending and Maintenance, separate implementation agents can work mostly inside their own contexts and git worktrees. The Lending agent can implement return processing and publish ToolReturned, while the Maintenance agent consumes that contract and implements inspection. The shared event schema is a narrow integration surface, which is much easier to review than two agents freely editing the same general service.

Why I do not trust full end-to-end automation

This is also why I remain sceptical of fully autonomous software delivery for non-trivial systems.

Agents can already write a large portion of the implementation. They can create tests, perform refactoring, and operate across a repository. That does not mean they can independently establish domain truth or maintain architectural coherence over time.

Without an explicit glossary, bounded contexts, context relationships, invariants, BDD specifications, and independent review, the generated system tends toward the average patterns found in its training data. Those patterns include many of the anti-patterns DDD was intended to prevent.

I use DDD to think through the design. Thinking through enables good abstractions. Good software engineering is all about designing good abstractions. It was never about writing code which is what LLM can do spectacularly well.

Designing clean architecture which follows the domain of the problem allows to cleanly run parallel agents which can work simultaneously without introducing merging hassles or creating spaghetti code. It helps maximize time and token spend.

The conclusion being, AI has no doubt reduced the effort required to use DDD in my own work. It helps me think, document, compare alternatives, generate implementation, and review the result.

It has NOT removed the need for me to decide what the software means.

If anyone tells you otherwise, they are probably in the business of selling tokens.