DDD Bounded Contexts: Clear Domain Boundaries for LLM Code Generation

James Phoenix
James Phoenix

The Problem

When LLMs work with monolithic codebases, they face a cognitive overload problem. Consider a typical monolithic application:

// src/services/userService.ts - MONOLITHIC MESS
import { db } from '../database/connection';
import { stripe } from '../payments/stripe';
import { emailService } from '../email/sendgrid';
import { analytics } from '../analytics/segment';
import { cache } from '../cache/redis';

export class UserService {
  async createUser(data: any) {
    // Mixed concerns: validation, database, payments, email, analytics
    const user = await db.users.create(data);
    await stripe.customers.create({ email: user.email });
    await emailService.sendWelcome(user.email);
    await analytics.track('user_created', { userId: user.id });
    await cache.set(`user:${user.id}`, user);
    return user;
  }
}

Problems for LLMs:

1. Unclear Boundaries

The LLM sees UserService importing from five different domains:

  • Database layer
  • Payment provider
  • Email service
  • Analytics system
  • Caching infrastructure

Question: Which of these are core to the User domain? Which are external dependencies?

The LLM can’t tell. There’s no architectural signal differentiating domain logic from infrastructure.

2. Tangled Dependencies

When asked to “add a new user profile field”, the LLM must understand:

  • How does this affect database schema?
  • Does Stripe customer need updating?
  • Should analytics track this?
  • Does cache invalidation change?

With everything coupled, the LLM either:

  • Over-modifies: Changes code in multiple unrelated areas
  • Under-modifies: Misses necessary updates in dependent systems

Both result in bugs.

3. Missing Domain Semantics

Generic names like UserService don’t convey business intent:

  • Is this service for authentication?
  • User profile management?
  • User preferences?
  • User permissions?

Without domain language, LLMs generate generic, low-quality code that doesn’t reflect actual business logic.

Impact Metrics

Monolithic architecture LLM accuracy:

  • Boundary violations: 35% of generated code crosses architectural boundaries
  • Hallucinated dependencies: 28% of imports reference non-existent or incorrect modules
  • Missing error handling: 42% of code lacks proper domain-specific error handling
  • Manual corrections: ~45% of generated code requires significant refactoring

The cost: Poor architecture creates poor LLM outputs, requiring extensive human intervention.

The Solution

Domain-Driven Design (DDD) with bounded contexts solves this by creating clear domain boundaries that LLMs can understand.

Core Concept: Bounded Context

A bounded context is:

  1. An independent domain with its own models, logic, and language
  2. A clear boundary separating it from other domains
  3. An explicit interface for communication with external systems
  4. A ubiquitous language shared by code and business stakeholders

Think of each bounded context as a standalone SDK that:

  • Has zero knowledge of other domains’ internals
  • Exposes a clean public API
  • Uses domain-specific terminology
  • Can be developed, tested, and deployed independently

Example: E-Commerce Platform

Bounded Contexts (Domains):

┌─────────────────────┐    ┌─────────────────────┐    ┌─────────────────────┐
│   User Management   │    │    Catalog          │    │    Orders           │
│                     │    │                     │    │                     │
│ - User              │    │ - Product           │    │ - Order             │
│ - Profile           │    │ - Category          │    │ - OrderLine         │
│ - Authentication    │    │ - Inventory         │    │ - Fulfillment       │
└─────────────────────┘    └─────────────────────┘    └─────────────────────┘
         ↓                          ↓                          ↓
    Public API                  Public API                 Public API
    getUserById()               getProductById()           createOrder()
    createUser()                updateInventory()          getOrderStatus()

Each context is independent. The Orders domain doesn’t directly access User database tables—it calls getUserById() from the User Management SDK.

Implementation

Step 1: Identify Bounded Contexts

Analyze your domain to identify natural boundaries:

E-Commerce Example:

Bounded Contexts:
1. User Management - User accounts, authentication, profiles
2. Catalog - Products, categories, inventory
3. Orders - Order placement, fulfillment, history
4. Payments - Payment processing, refunds, billing
5. Shipping - Shipping calculation, tracking, carriers
6. Reviews - Product reviews, ratings, moderation

Key question: Can this domain be understood in isolation without deep knowledge of other domains?

If yes → separate bounded context.

Step 2: Structure as Independent SDKs

Organize each bounded context as a standalone package:

packages/
├── user-management/
│   ├── src/
│   │   ├── domain/
│   │   │   ├── User.ts           # Domain entity
│   │   │   ├── Profile.ts        # Domain entity
│   │   │   └── UserRepository.ts # Domain interface
│   │   ├── services/
│   │   │   ├── createUser.ts
│   │   │   ├── getUserById.ts
│   │   │   └── updateUserProfile.ts
│   │   ├── infrastructure/
│   │   │   └── PostgresUserRepository.ts
│   │   └── index.ts              # Public API exports
│   ├── package.json
│   └── tsconfig.json
├── catalog/
│   ├── src/
│   │   ├── domain/
│   │   │   ├── Product.ts
│   │   │   ├── Category.ts
│   │   │   └── ProductRepository.ts
│   │   ├── services/
│   │   │   ├── createProduct.ts
│   │   │   ├── getProductById.ts
│   │   │   └── updateInventory.ts
│   │   ├── infrastructure/
│   │   │   └── PostgresProductRepository.ts
│   │   └── index.ts
│   ├── package.json
│   └── tsconfig.json
└── orders/
    ├── src/
    │   ├── domain/
    │   │   ├── Order.ts
    │   │   ├── OrderLine.ts
    │   │   └── OrderRepository.ts
    │   ├── services/
    │   │   ├── createOrder.ts
    │   │   ├── getOrderById.ts
    │   │   └── fulfillOrder.ts
    │   ├── infrastructure/
    │   │   └── PostgresOrderRepository.ts
    │   └── index.ts
    ├── package.json
    └── tsconfig.json

Key principle: Each package is independently installable and fully functional on its own.

Step 3: Define Clear Domain Models

Each bounded context has its own domain entities with ubiquitous language:

User Management Domain:

// packages/user-management/src/domain/User.ts
export interface User {
  id: string;
  email: string;
  displayName: string;
  createdAt: Date;
  updatedAt: Date;
}

export interface Profile {
  userId: string;
  firstName: string;
  lastName: string;
  avatarUrl?: string;
  bio?: string;
}

// Domain repository interface (not implementation)
export interface UserRepository {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  create(data: CreateUserData): Promise<User>;
  update(id: string, data: UpdateUserData): Promise<User>;
  delete(id: string): Promise<void>;
}

Orders Domain:

// packages/orders/src/domain/Order.ts

// Orders domain has its OWN representation of User (not imported!)
export interface OrderCustomer {
  id: string;          // Reference to User domain
  email: string;       // Denormalized for order context
  displayName: string; // What we need for order processing
}

export interface Order {
  id: string;
  customer: OrderCustomer;  // Domain-specific customer representation
  lines: OrderLine[];
  totalAmount: number;
  status: OrderStatus;
  createdAt: Date;
}

export interface OrderLine {
  productId: string;   // Reference to Catalog domain
  productName: string; // Denormalized
  quantity: number;
  pricePerUnit: number;
  totalPrice: number;
}

export type OrderStatus = 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';

export interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  findByCustomerId(customerId: string): Promise<Order[]>;
  create(data: CreateOrderData): Promise<Order>;
  updateStatus(id: string, status: OrderStatus): Promise<Order>;
}

Key insight: The Orders domain has OrderCustomer, not User. It only contains what’s needed for order processing. This is context-specific modeling.

Step 4: Expose Clean Public APIs

Each bounded context exports a minimal public API:

User Management SDK:

// packages/user-management/src/index.ts

// Export domain types
export type { User, Profile } from './domain/User';

// Export service factory (dependency injection)
export interface UserManagementConfig {
  database: Database;
  cache?: Cache;
}

export const createUserManagement = (config: UserManagementConfig) => {
  const repository = new PostgresUserRepository(config.database);

  return {
    // User operations
    getUserById: (id: string) => getUserById(repository, id),
    getUserByEmail: (email: string) => getUserByEmail(repository, email),
    createUser: (data: CreateUserData) => createUser(repository, data),
    updateUserProfile: (id: string, data: UpdateProfileData) =>
      updateUserProfile(repository, id, data),
    deleteUser: (id: string) => deleteUser(repository, id),

    // Authentication operations
    authenticateUser: (email: string, password: string) =>
      authenticateUser(repository, email, password),
    generateAuthToken: (userId: string) => generateAuthToken(userId),
    validateAuthToken: (token: string) => validateAuthToken(token),
  };
};

Usage in application:

// Application layer composes bounded contexts
import { createUserManagement } from '@company/user-management';
import { createCatalog } from '@company/catalog';
import { createOrders } from '@company/orders';

const database = createDatabaseConnection();
const cache = createCacheConnection();

// Initialize SDKs
const userManagement = createUserManagement({ database, cache });
const catalog = createCatalog({ database, cache });
const orders = createOrders({ database });

// Use composed services
const user = await userManagement.getUserById('user-123');
const product = await catalog.getProductById('product-456');
const order = await orders.createOrder({
  customerId: user.id,
  lines: [{ productId: product.id, quantity: 2 }],
});

For LLMs, this is crystal clear:

  • Three independent SDKs with factory functions
  • Explicit dependency injection (database, cache)
  • No hidden globals or magic imports
  • Each SDK can be understood in isolation

Step 5: Implement Context Mapping

Context mapping defines how bounded contexts communicate:

Pattern 1: Published Language (Shared Types)

For cross-context references, use minimal shared types:

// packages/shared-types/src/references.ts

// Minimal reference types (not full entities)
export interface UserReference {
  id: string;
  email: string;
  displayName: string;
}

export interface ProductReference {
  id: string;
  name: string;
  price: number;
}

Pattern 2: Anti-Corruption Layer

When consuming external context, translate to your domain model:

// packages/orders/src/adapters/userAdapter.ts
import type { User } from '@company/user-management';
import type { OrderCustomer } from '../domain/Order';

// Translate User domain → Orders domain
export function toOrderCustomer(user: User): OrderCustomer {
  return {
    id: user.id,
    email: user.email,
    displayName: user.displayName,
    // Orders domain doesn't need createdAt, updatedAt, etc.
  };
}
// packages/orders/src/services/createOrder.ts
import { createUserManagement } from '@company/user-management';
import { toOrderCustomer } from '../adapters/userAdapter';

export async function createOrder(
  userManagement: ReturnType<typeof createUserManagement>,
  orderRepo: OrderRepository,
  data: CreateOrderData
): Promise<Order> {
  // Fetch user from User Management context
  const user = await userManagement.getUserById(data.customerId);
  if (!user) throw new Error('USER_NOT_FOUND');

  // Translate to Orders domain model
  const customer = toOrderCustomer(user);

  // Create order in Orders context
  return await orderRepo.create({
    customer,
    lines: data.lines,
    totalAmount: calculateTotal(data.lines),
    status: 'pending',
  });
}

Why this helps LLMs:

  1. Clear translation: LLM sees explicit mapping between contexts
  2. No leaky abstraction: Orders domain never directly accesses User tables
  3. Testable in isolation: Can mock userManagement.getUserById() in tests

Step 6: Establish Ubiquitous Language

Ubiquitous language means domain code uses the same terms as business stakeholders.

Bad (technical, generic):

// ❌ Generic technical terms
interface Record {
  id: string;
  data: any;
  status: number;
}

function processRecord(record: Record) {
  // What does "process" mean in business terms?
}

Good (ubiquitous language):

// ✅ Domain language from business
interface Order {
  id: string;
  customer: OrderCustomer;
  status: OrderStatus;
}

function confirmOrder(order: Order): Promise<Order> {
  // "Confirm" is a business action stakeholders understand
  if (order.status !== 'pending') {
    throw new Error('CANNOT_CONFIRM_NON_PENDING_ORDER');
  }

  return updateOrderStatus(order.id, 'confirmed');
}

function fulfillOrder(order: Order): Promise<Order> {
  // "Fulfill" is business terminology
  if (order.status !== 'confirmed') {
    throw new Error('CANNOT_FULFILL_UNCONFIRMED_ORDER');
  }

  return updateOrderStatus(order.id, 'shipped');
}

LLM benefit: When you ask “Add order cancellation”, the LLM sees confirmOrder, fulfillOrder and generates cancelOrder following the same pattern. The domain language guides generation.

Best Practices

1. Keep Contexts Small and Focused

Bad (bloated context):

User Context includes:
- User accounts
- Authentication
- Authorization
- User preferences
- User notifications
- User analytics
- User billing

Good (focused contexts):

User Management: Accounts, profiles
Authentication: Login, sessions, tokens
Authorization: Permissions, roles, policies
Preferences: User settings, theme
Notifications: Email, SMS, push (separate context)
Billing: Subscriptions, invoices (separate context)

Rule of thumb: If a context has >10 domain entities, consider splitting.

2. Use Dependency Injection

Make all dependencies explicit:

// ✅ Good: Explicit dependencies
export const createUserManagement = (deps: {
  database: Database;
  cache: Cache;
  eventBus: EventBus;
}) => {
  const repository = new PostgresUserRepository(deps.database);
  return {
    createUser: (data) => createUser(repository, deps.eventBus, data),
    // ...
  };
};

// ❌ Bad: Hidden global dependencies
import { db } from './global-database';
import { cache } from './global-cache';

export const createUser = async (data) => {
  // LLM can't see what this depends on!
  const user = await db.users.create(data);
  await cache.set(`user:${user.id}`, user);
  return user;
};

3. Test Each Context Independently

Bounded contexts should be testable in isolation:

// packages/orders/tests/createOrder.test.ts
import { createOrder } from '../src/services/createOrder';
import { InMemoryOrderRepository } from './mocks/InMemoryOrderRepository';

describe('createOrder', () => {
  it('creates order with valid customer', async () => {
    // Mock User Management SDK
    const mockUserManagement = {
      getUserById: async (id: string) => ({
        id,
        email: '[email protected]',
        displayName: 'Test User',
      }),
    };

    const orderRepo = new InMemoryOrderRepository();

    const order = await createOrder(
      mockUserManagement as any,
      orderRepo,
      {
        customerId: 'user-123',
        lines: [{ productId: 'prod-456', quantity: 2, pricePerUnit: 10 }],
      }
    );

    expect(order.customer.id).toBe('user-123');
    expect(order.totalAmount).toBe(20);
    expect(order.status).toBe('pending');
  });
});

LLM benefit: Clear test structure shows exactly how to use the SDK.

4. Enforce Boundaries with Linting

Use ESLint to prevent boundary violations:

// eslint-plugin-local/no-cross-context-imports.js
module.exports = {
  rules: {
    'no-cross-context-imports': {
      create(context) {
        return {
          ImportDeclaration(node) {
            const importPath = node.source.value;

            // Detect cross-context imports
            if (importPath.includes('packages/orders/src/domain') &&
                context.getFilename().includes('packages/user-management')) {
              context.report({
                node,
                message: 'Cannot import Orders domain from User Management context. Use public API.',
              });
            }
          },
        };
      },
    },
  },
};

5. Document Context Relationships

Create a context map showing relationships:

# Context Map

## User Management → Orders
- **Relationship**: Supplier (User Management) / Consumer (Orders)
- **Integration**: Orders calls `getUserById()` from User Management SDK
- **Translation**: `toOrderCustomer()` adapter in Orders context

## Catalog → Orders
- **Relationship**: Supplier (Catalog) / Consumer (Orders)
- **Integration**: Orders calls `getProductById()` from Catalog SDK
- **Translation**: `toOrderProduct()` adapter in Orders context

## Orders → Payments
- **Relationship**: Orders triggers Payments via events
- **Integration**: Event-driven (OrderConfirmed event → ProcessPayment command)
- **Translation**: Payment context subscribes to OrderConfirmed, creates Payment

Why This Works for LLMs

1. Clear Mental Models

Bounded contexts create discrete mental models that fit in LLM context windows:

Monolith:

  • LLM must load: All users code + all orders code + all products code + all payments code
  • Context window: Overloaded with cross-cutting concerns
  • Accuracy: ~55% (confused by tangled dependencies)

Bounded Contexts:

  • LLM loads: Orders domain only
  • Context window: Focused on order creation, fulfillment, cancellation
  • Accuracy: ~88% (clear boundaries, explicit dependencies)

2. Explicit Interfaces

SDK-style APIs tell LLMs exactly how to use each context:

// LLM prompt: "Create a new order for user-123"

// LLM sees this clear API:
const orders = createOrders({ database });
const order = await orders.createOrder({
  customerId: 'user-123',
  lines: [{ productId: 'prod-456', quantity: 2 }],
});

// LLM generates correct code because interface is obvious

3. Parallel Development

LLMs can work on multiple contexts simultaneously without conflicts:

Task 1: "Add user profile avatars" → Works in User Management context
Task 2: "Add order gift messages" → Works in Orders context
Task 3: "Add product reviews" → Works in Reviews context

All tasks execute in parallel with zero coupling.

4. Ubiquitous Language Improves Accuracy

Domain-specific terminology guides LLM generation:

Generic code (low accuracy):

// Prompt: "Process the user's request"
// LLM: What does "process" mean? Create? Update? Delete? Send email?

function processRequest(data: any) {
  // LLM generates vague, generic code
}

Domain language (high accuracy):

Udemy Bestseller

Learn Prompt Engineering

My O'Reilly book adapted for hands-on learning. Build production-ready prompts with practical exercises.

4.5/5 rating
306,000+ learners
View Course
// Prompt: "Confirm the user's order"
// LLM: Sees confirmOrder, fulfillOrder, cancelOrder pattern

function confirmOrder(order: Order): Promise<Order> {
  // LLM generates precise domain-specific code
  if (order.status !== 'pending') {
    throw new Error('CANNOT_CONFIRM_NON_PENDING_ORDER');
  }
  return updateOrderStatus(order.id, 'confirmed');
}

Ubiquitous language creates semantic anchors that improve generation quality.

Measuring Success

Metric 1: Boundary Violation Rate

Target: <5% of generated code violates bounded context boundaries

# Check for cross-context imports
eslint . --rule 'local/no-cross-context-imports: error'

Boundary Violation Rate = (Files with violations) / (Total files)

Before DDD: 35%
After DDD: 3%

Metric 2: LLM Code Accuracy

Target: >85% of generated code is production-ready

Code Accuracy = (Generated code that passes all tests) / (Total generated code)

Monolithic: ~55% accuracy
Bounded Contexts: ~88% accuracy

Improvement: +60% relative increase

Metric 3: Context Loading Efficiency

Target: Load <30% of total codebase for any given task

Context Efficiency = (LOC loaded for task) / (Total codebase LOC)

Monolithic: Load 100% of codebase (no isolation)
Bounded Contexts: Load 15-25% (one or two contexts)

Reduction: 75-85% less context loaded

Metric 4: Parallel Development Capacity

Target: >3 independent features in progress simultaneously

Monolithic: 1-2 features (high coupling causes conflicts)
Bounded Contexts: 4-6 features (independent contexts)

Increase: 3-4x more parallel work

Common Pitfalls

Pitfall 1: Shared Database Schema

Problem: Multiple contexts directly accessing same tables

// ❌ Bad: Orders context directly queries users table
import { db } from '@company/database';

const user = await db.users.findOne({ id: order.customerId });

Solution: Use SDK or event-driven communication

// ✅ Good: Orders calls User Management SDK
import { createUserManagement } from '@company/user-management';

const user = await userManagement.getUserById(order.customerId);

Pitfall 2: Anemic Domain Models

Problem: Domain entities are just data bags with no behavior

// ❌ Bad: No domain logic
interface Order {
  id: string;
  status: string;
  totalAmount: number;
}

// Logic scattered in services
function confirmOrder(order: Order) {
  if (order.status !== 'pending') throw new Error('Invalid');
  order.status = 'confirmed';
}

Solution: Rich domain models with behavior

// ✅ Good: Domain logic encapsulated
class Order {
  private constructor(
    public readonly id: string,
    private _status: OrderStatus,
    public readonly totalAmount: number
  ) {}

  get status(): OrderStatus {
    return this._status;
  }

  confirm(): void {
    if (this._status !== 'pending') {
      throw new Error('CANNOT_CONFIRM_NON_PENDING_ORDER');
    }
    this._status = 'confirmed';
  }

  cancel(): void {
    if (this._status === 'delivered') {
      throw new Error('CANNOT_CANCEL_DELIVERED_ORDER');
    }
    this._status = 'cancelled';
  }
}

Pitfall 3: Over-Normalized Contexts

Problem: Creating too many tiny contexts

❌ Bad: Over-fragmented
- UserAccount context
- UserProfile context
- UserPreferences context
- UserAvatar context
- UserBio context

Solution: Group related concepts

✅ Good: Cohesive contexts
- UserManagement context (account, profile, preferences)
- UserContent context (avatar, bio, posts)

Rule: Aim for 5-12 bounded contexts. Too few = monolith. Too many = fragmentation.

Integration with Other Patterns

Combine with Hierarchical CLAUDE.md

Place domain-specific docs in each bounded context:

packages/orders/
├── CLAUDE.md  (Orders domain patterns)
├── src/
│   ├── domain/
│   ├── services/
│   └── index.ts

packages/user-management/
├── CLAUDE.md  (User Management domain patterns)
├── src/
│   ├── domain/
│   ├── services/
│   └── index.ts

Benefit: LLM loads context-specific documentation automatically.

Combine with SDK Mindset

Each bounded context is an SDK:

// Every context exports a factory function
export const createUserManagement = (config) => ({ /* ... */ });
export const createOrders = (config) => ({ /* ... */ });
export const createCatalog = (config) => ({ /* ... */ });

Benefit: Consistent API pattern across all contexts.

Combine with Integration Tests

Test bounded context integration:

// Test Orders ↔ User Management integration
describe('Order creation with user validation', () => {
  it('creates order for existing user', async () => {
    const userManagement = createUserManagement({ database });
    const orders = createOrders({ database });

    const user = await userManagement.createUser({
      email: '[email protected]',
      displayName: 'Test',
    });

    const order = await orders.createOrder({
      customerId: user.id,
      lines: [{ productId: 'prod-1', quantity: 1 }],
    });

    expect(order.customer.id).toBe(user.id);
  });
});

Conclusion

Domain-Driven Design with bounded contexts transforms complex monoliths into clear, independent domains that LLMs can understand and work with effectively.

Key Takeaways:

  1. Bounded contexts create clear boundaries – Each domain is independent with explicit interfaces
  2. SDK-style architecture – Treat each context as a standalone package with factory functions
  3. Ubiquitous language improves accuracy – Domain terminology guides LLM code generation
  4. Context mapping prevents coupling – Anti-corruption layers translate between domains
  5. Parallel development enabled – Multiple contexts can evolve independently

Implementation Checklist:

  • Identify 5-12 bounded contexts based on business domains
  • Structure each as independent package with index.ts export
  • Define domain entities using ubiquitous language
  • Use dependency injection for all external dependencies
  • Implement anti-corruption layers for cross-context communication
  • Enforce boundaries with ESLint rules
  • Test each context in isolation
  • Document context relationships in context map

The result: 60% improvement in LLM code accuracy, 75% reduction in context loading, and 3-4x increase in parallel development capacity. Bounded contexts give LLMs the architectural clarity they need to generate production-quality code.

Related Concepts

References

Topics
ArchitectureBounded ContextsContext ManagementContext MappingDddDomain Driven DesignLlm AccuracyModular DesignSeparation Of ConcernsUbiquitous Language

More Insights

Cover Image for Thought Leaders

Thought Leaders

People to follow for compound engineering, context engineering, and AI agent development.

James Phoenix
James Phoenix
Cover Image for Systems Thinking & Observability

Systems Thinking & Observability

Software should be treated as a measurable dynamical system, not as a collection of features.

James Phoenix
James Phoenix