Layered Prompts: Onion Architecture for AI Coding Agents

James Phoenix
James Phoenix

Summary

Structure prompts using onion architecture with four layers: Core (universal rules), Domain (project patterns), Application (feature context), and Task (specific request). This approach enables independent versioning, layer reuse, A/B testing, and integration with prompt caching for 90% cost reduction. Each layer changes at different frequencies, making prompts maintainable and composable.

The Problem

Monolithic prompts that dump all context at once are difficult to maintain, version control, and reuse. When working with multiple projects or iterating on development approaches, you must either duplicate context across projects or manually extract reusable pieces. Mixing stable rules (coding standards) with dynamic requests (current task) prevents prompt caching optimizations, adding unnecessary cost. Changes to shared patterns require editing every prompt using that pattern.

The Solution

Layer prompts using onion architecture: Core (universal principles that apply everywhere), Domain (project-specific patterns), Application (feature context), and Task (specific request). Each layer changes at different frequencies – Core rarely changes, Domain changes per project, Application changes per feature, Task changes per request. This separation enables independent versioning, layer reuse across projects, A/B testing by swapping layers, and full compatibility with prompt caching. The immutable outer layers cache efficiently while inner layers remain dynamic.

The Problem

Most developers structure prompts as a single monolithic block:

You are an expert TypeScript developer.
Follow functional programming principles.
Write tests for all functions.

This project uses factory pattern, no classes.
Database access via Supabase client.
All errors use custom error classes.

We're building a social media scheduler.
Posts are stored in 'posts' table.
Support Instagram, Twitter, LinkedIn.

Add Instagram post validation.
Check image dimensions and aspect ratio.
Return detailed error messages.

Why This Approach Fails

1. Maintenance Nightmare

When you decide “all errors should use custom error classes,” you must update this rule in:

  • Every project prompt
  • Every cached prompt
  • Every documentation file

Changes to shared patterns require finding and editing every location that mentions them.

2. No Reusability

The “TypeScript expert” layer is identical across projects, but duplicating it means:

  • Manual copy-paste across prompts
  • Risk of drift (one project gets updated, another doesn’t)
  • Version confusion (which version of the standard is this prompt using?)

3. Breaks Prompt Caching

Prompt caching only works when stable content is at the beginning and dynamic content is at the end. Mixing them prevents caching:

❌ Problem:
[Universal rules (stable)]
[Project patterns (stable)]
[Task-specific request (dynamic)]  ← Cache breaks here
[Specific implementation details (dynamic)]

Without caching, every request pays full price for repeated context.

4. Difficult A/B Testing

Can’t easily test “What if we used composition over inheritance?” because the principle is buried in a monolithic block. To test, you must:

  1. Copy the entire prompt
  2. Find the right sentence
  3. Edit it
  4. Track which version is which

5. Version Control Chaos

When you update coding standards, the diff shows the entire prompt changing, making it impossible to see what actually changed:

- You are an expert TypeScript developer.
+ You are a world-class TypeScript developer.
 (rest of prompt identical but shows as changed)

The Solution: Layered Prompts

Instead of a monolithic block, structure prompts as four layers with increasing specificity:

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

Layer 1: Core (Universal, Rarely Changes)

Principles that apply to all projects:

## Core Development Principles

You are an expert TypeScript developer.
Follow functional programming principles.
Write tests for all functions.
Avoid side effects where possible.
Use type safety as a quality gate.
Favor composition over inheritance.

Characteristics:

  • Universal across all projects
  • Rarely changes (maybe quarterly)
  • Most stable layer
  • Foundation for everything else

Layer 2: Domain (Project-Specific, Changes Per Project)

Patterns specific to this project:

## Project Architecture (Acme Social Scheduler)

This project uses the factory pattern for service creation (no classes).
Database access is exclusively through the Supabase client.
All errors must use custom error classes from @acme/errors.
API responses use tRPC with Zod validation.
Test files live alongside source files: `feature.ts` + `feature.test.ts`.

Characteristics:

  • Project-specific patterns
  • Changes when project architecture evolves
  • Shared by all features in the project
  • Independent of current task

Layer 3: Application (Feature Context, Changes Per Feature)

Context for this feature:

## Social Media Scheduler Feature

We're building a scheduler that manages posts across multiple platforms.
Posts are stored in the 'posts' table with columns: id, title, content, scheduled_at, platforms, status.
Supported platforms: Instagram, Twitter, LinkedIn.
Scheduled posts must be queued for delivery at their scheduled_at timestamp.

Characteristics:

  • Feature-specific context
  • Changes when working on different features
  • Includes data models, workflows, constraints
  • Stable during implementation of a single feature

Layer 4: Task (Specific Request, Changes Per Request)

The specific task for this request:

## Current Task: Instagram Post Validation

Add validation for Instagram posts before scheduling.
Validation rules:
1. Image must be square (1:1 aspect ratio) or vertical (4:5)
2. Image dimensions must be >= 1080x1080px
3. Caption must be <= 2200 characters
4. Hashtags must be <= 30 per post

Implement as a validation function: `validateInstagramPost(post: Post): ValidationError[]`
Return detailed error messages for each validation failure.

Characteristics:

  • Specific to this request
  • Changes on every request
  • Most dynamic layer
  • Where you put task-specific details

Implementation Pattern

Basic Structure

const layeredPrompt = [
  coreLayer,        // Universal principles
  domainLayer,      // Project patterns
  applicationLayer, // Feature context
  taskLayer,        // Specific request
].join('\n\n---\n\n');

// Result:
// [Core principles]
// --- 
// [Project patterns]
// ---
// [Feature context]
// ---
// [Specific task]

Complete Example

const coreLayer = `
## Core Development Principles

You are an expert TypeScript developer.
Follow functional programming principles.
Write tests for all functions.
Use type safety as a quality gate.
Favor composition over inheritance.
Avoid side effects in pure functions.
`;

const domainLayer = `
## Project Architecture (Acme Social Scheduler)

This project uses factory pattern for services (no classes).
Database access via Supabase client only.
All errors use custom error classes from @acme/errors.
API endpoints use tRPC with Zod validation.
Test files colocated with source: feature.ts + feature.test.ts.
`;

const applicationLayer = `
## Social Media Scheduler Feature

Schedules posts across Instagram, Twitter, LinkedIn.
Posts stored in 'posts' table: id, title, content, scheduled_at, platforms, status.
Delivery happens via background workers polling the queue.
`;

const taskLayer = `
## Current Task: Instagram Post Validation

Implement validation function: validateInstagramPost(post: Post): ValidationError[]

Rules:
- Image: square (1:1) or vertical (4:5), >= 1080x1080px
- Caption: <= 2200 characters
- Hashtags: <= 30

Return detailed errors for each violation.
`;

const prompt = [
  coreLayer,
  domainLayer,
  applicationLayer,
  taskLayer,
].join('\n\n---\n\n');

const response = await client.messages.create({
  model: 'claude-sonnet-4',
  max_tokens: 4096,
  messages: [
    {
      role: 'user',
      content: prompt,
    },
  ],
});

Key Benefits

1. Independent Versioning

Each layer can be versioned independently:

const v2_coreLayer = `
## Core Development Principles (v2)

- New: "Favor explicit over implicit"
- New: "Use strict null checking everywhere"
`;

const v1_domainLayer = `
## Project Architecture (v1)

(unchanged from before)
`;

// Mix versions freely
const prompt = [
  v2_coreLayer,  // Using new version
  v1_domainLayer, // Still on v1
  applicationLayer,
  taskLayer,
].join('\n\n---\n\n');

2. Layer Reusability

Reuse the same coreLayer across all projects:

// Project A
const promptA = [
  coreLayer,           // Same everywhere
  projectA_domainLayer,
  projectA_appLayer,
  taskA,
].join('\n\n---\n\n');

// Project B
const promptB = [
  coreLayer,           // Same universal principles
  projectB_domainLayer,
  projectB_appLayer,
  taskB,
].join('\n\n---\n\n');

3. A/B Testing

Swap layers to test different approaches:

// Test 1: Current approach
const prompt_v1 = [
  coreLayer,
  domainLayerV1, // "Favor composition over inheritance"
  applicationLayer,
  taskLayer,
].join('\n\n---\n\n');

// Test 2: Alternative approach
const prompt_v2 = [
  coreLayer,
  domainLayerV2, // "Favor inheritance for shared behavior"
  applicationLayer,
  taskLayer,
].join('\n\n---\n\n');

const [response1, response2] = await Promise.all([
  client.messages.create({ messages: [{ role: 'user', content: prompt_v1 }] }),
  client.messages.create({ messages: [{ role: 'user', content: prompt_v2 }] }),
]);

// Compare outputs

4. Prompt Caching Optimization

When combined with prompt caching, outer layers cache efficiently:

const response = await client.messages.create({
  model: 'claude-sonnet-4',
  max_tokens: 4096,
  messages: [
    {
      role: 'user',
      content: [
        {
          type: 'text',
          text: [coreLayer, domainLayer, applicationLayer].join('\n\n---\n\n'),
          cache_control: { type: 'ephemeral' }, // Mark for caching
        },
        {
          type: 'text',
          text: taskLayer, // Dynamic, not cached
        },
      ],
    },
  ],
});

// Cost breakdown:
// First request: 
//   Cached layers: 4K tokens × $0.003 = $0.012
//   Dynamic layer: 100 tokens × $0.003 = $0.0003
//   Total: $0.0123
//
// Subsequent requests (within 5 min):
//   Cached layers: 4K tokens × $0.0003 = $0.0012
//   Dynamic layer: 100 tokens × $0.003 = $0.0003
//   Total: $0.0015 (88% savings)

5. Clear Separation of Concerns

Each layer has a single responsibility:

  • Core: Universal development principles
  • Domain: Project-specific architecture
  • Application: Feature context and data models
  • Task: Specific implementation request

This clarity makes prompts easier to understand and maintain.

Implementation Patterns

Pattern 1: Storage in Files

// core-layer.md
export const coreLayer = fs.readFileSync('layers/core.md', 'utf-8');

// domain-layer.ts
export function getDomainLayer(projectName: string) {
  return fs.readFileSync(`layers/${projectName}/domain.md`, 'utf-8');
}

// application-layer.ts
export function getApplicationLayer(feature: string) {
  return fs.readFileSync(`layers/applications/${feature}.md`, 'utf-8');
}

// Usage
const prompt = [
  coreLayer,
  getDomainLayer('acme'),
  getApplicationLayer('social-scheduler'),
  `Implement user authentication`,
].join('\n\n---\n\n');

Pattern 2: Programmatic Layer Building

interface LayerConfig {
  coreVersion: 'v1' | 'v2';
  projectId: string;
  feature: string;
  task: string;
}

function buildLayeredPrompt(config: LayerConfig): string {
  const coreLayer = config.coreVersion === 'v1' 
    ? getCoreLayerV1() 
    : getCoreLayerV2();
  
  const domainLayer = getDomainLayer(config.projectId);
  const appLayer = getApplicationLayer(config.projectId, config.feature);
  
  return [
    coreLayer,
    domainLayer,
    appLayer,
    config.task,
  ].join('\n\n---\n\n');
}

// Usage
const prompt = buildLayeredPrompt({
  coreVersion: 'v2',
  projectId: 'acme',
  feature: 'social-scheduler',
  task: 'Implement Instagram validation',
});

Pattern 3: Dynamic Layer Selection

function selectLayers(context: Context) {
  // Choose layers based on runtime context
  const layers = [
    coreLayer, // Always included
  ];
  
  // Add domain layer only if project supports it
  if (context.project?.hasArchitecture) {
    layers.push(getDomainLayer(context.project.id));
  }
  
  // Add application layer only for feature work
  if (context.feature) {
    layers.push(getApplicationLayer(context.feature));
  }
  
  // Always add task layer
  layers.push(context.task);
  
  return layers.join('\n\n---\n\n');
}

Best Practices

1. Keep Layers Stable

Problem: Frequently changing layers break caching and reduce reusability.

Solution: Only change layers when the information genuinely applies to multiple requests.

// ❌ Bad: Task-specific details in application layer
const appLayer = `
We're building a social media scheduler.
Right now we need to validate Instagram posts.
Images must be square or vertical.
`;

// ✅ Good: Only general feature context in application layer
const appLayer = `
We're building a social media scheduler.
Posts are stored in 'posts' table.
Supported platforms: Instagram, Twitter, LinkedIn.
`;

// Task-specific details go in task layer
const taskLayer = `
Implement Instagram post validation.
Images must be square (1:1) or vertical (4:5).
`;

2. Use Clear Delimiters

Make it obvious where each layer begins and ends:

## LAYER 1: CORE PRINCIPLES

[Content]

---

## LAYER 2: PROJECT ARCHITECTURE

[Content]

---

## LAYER 3: FEATURE CONTEXT

[Content]

---

## LAYER 4: SPECIFIC TASK

[Content]

3. Document Layer Boundaries

Make it clear which information belongs in which layer:

interface LayerGuideline {
  name: string;
  changeFrequency: string;
  examples: string[];
}

const guidelines: Record<string, LayerGuideline> = {
  core: {
    name: 'Core',
    changeFrequency: 'Quarterly or when principles change',
    examples: [
      'Language expertise (TypeScript)',
      'Development paradigms (functional)',
      'Quality standards (write tests)',
    ],
  },
  domain: {
    name: 'Domain',
    changeFrequency: 'When project architecture evolves',
    examples: [
      'Design patterns (factory pattern)',
      'Technology choices (Supabase)',
      'Error handling approach (custom classes)',
    ],
  },
  application: {
    name: 'Application',
    changeFrequency: 'When switching features',
    examples: [
      'Feature name (social scheduler)',
      'Data models (posts table)',
      'Supported integrations (Instagram, Twitter)',
    ],
  },
  task: {
    name: 'Task',
    changeFrequency: 'Every request',
    examples: [
      'Specific implementation (add validation)',
      'Current requirements (square aspect ratio)',
      'Return format (ValidationError[])',
    ],
  },
};

4. Test Layer Combinations

Ensure layers work well together:

test('core + domain layers produce valid prompts', () => {
  const prompt = [coreLayer, domainLayer].join('\n\n---\n\n');
  expect(prompt).toContain('TypeScript');
  expect(prompt).toContain('factory pattern');
  expect(prompt.length).toBeGreaterThan(500);
});

test('all four layers produce coherent prompt', () => {
  const prompt = [
    coreLayer,
    domainLayer,
    applicationLayer,
    taskLayer,
  ].join('\n\n---\n\n');
  
  // Verify no contradictions
  const mentionsFactory = prompt.includes('factory');
  const mentionsClasses = prompt.includes('classes');
  expect(!(mentionsFactory && mentionsClasses)).toBe(true);
});

5. Version Layers Explicitly

Track which version of each layer you’re using:

interface PromptVersion {
  coreVersion: string;
  domainVersion: string;
  applicationVersion: string;
  taskDescription: string;
}

const versionHistory: PromptVersion[] = [
  {
    coreVersion: 'v1',
    domainVersion: 'acme-v2',
    applicationVersion: 'scheduler-v1',
    taskDescription: 'Instagram validation',
  },
];

// In git commits
git commit -m "Update core layer to v2: add explicit-over-implicit principle"
git commit -m "Update acme domain to v3: switch to composition pattern"

Combining with Prompt Caching

Layered prompts work beautifully with prompt caching:

const cachedLayers = [
  coreLayer,        // Cached: rarely changes
  domainLayer,      // Cached: stable per project
  applicationLayer, // Cached: stable per feature
].join('\n\n---\n\n');

const response = await client.messages.create({
  model: 'claude-sonnet-4',
  max_tokens: 4096,
  messages: [
    {
      role: 'user',
      content: [
        {
          type: 'text',
          text: cachedLayers,
          cache_control: { type: 'ephemeral' }, // Enable caching
        },
        {
          type: 'text',
          text: taskLayer, // Dynamic layer not cached
        },
      ],
    },
  ],
});

// Results:
// - First request of day: ~$0.012 (full cost)
// - Next 49 requests: ~$0.0015 each (88% savings)
// - Daily savings: ~$0.45
// - Monthly savings: ~$10
// - Yearly savings: ~$120

Measuring Success

Metrics to Track

  1. Layer Reusability: How many prompts use each layer?

    coreLayer: used in 100% of prompts
    domainLayer: used in 95% of project A prompts
    applicationLayer: used in 87% of feature A prompts
    
  2. Cache Hit Rate: How often are cached layers used?

    Target: >80% of requests hit the cached layers
    
  3. Maintenance Burden: How often do layers change?

    coreLayer: 2-4 times/year
    domainLayer: 1-2 times/quarter
    applicationLayer: 1-2 times/week
    
  4. Cost Savings: Combined with caching

    Without layering: $45/month
    With layering only: $35/month (22% savings from reduced duplication)
    With layering + caching: $5/month (89% savings)
    

Common Pitfalls

❌ Pitfall 1: Too Many Layers

Problem: Creating layers for every detail creates confusion

// ❌ Too many layers
const layers = [
  coreLayer,
  domainLayer,
  architectureLayer,
  databaseLayer,
  apiLayer,
  authenticationLayer,
  applicationLayer,
  taskLayer,
]; // 8 layers - too many to manage

Solution: Stick to 4 layers max

// ✅ Right number of layers
const layers = [
  coreLayer,
  domainLayer,
  applicationLayer,
  taskLayer,
]; // 4 layers - clear and manageable

❌ Pitfall 2: Mixing Change Frequencies

Problem: Putting frequently-changing content in cached layers

// ❌ Wrong: task details in application layer
const appLayer = `
We're building a scheduler.
This week we're implementing Instagram validation.
Next week we'll add Twitter support.
`;

Solution: Put only stable content in outer layers

// ✅ Correct: only architectural details
const appLayer = `
We're building a scheduler.
Supported platforms: Instagram, Twitter, LinkedIn.
Posts stored in 'posts' table.
`;

❌ Pitfall 3: Inconsistent Formatting

Problem: Different formatting breaks byte-for-byte matching for caching

// ❌ Request 1
const domainLayer1 = 'Errors use CustomError class';

// ❌ Request 2 (with extra spaces)
const domainLayer2 = 'Errors  use  CustomError  class';
// Different bytes = cache miss

Solution: Normalize and freeze layer content

// ✅ Correct: keep content immutable
export const domainLayer = `
Errors use CustomError class.
Database via Supabase client.
`;
// Every reference uses identical content

❌ Pitfall 4: Duplicating Core Content

Problem: Different versions of the core layer across projects

// ❌ Project A
const coreA = `You are an expert TypeScript developer.`;

// ❌ Project B (subtly different)
const coreB = `You are a world-class TypeScript developer.`;
// Different content = can't reuse

Solution: Maintain single version of core layer

// ✅ Correct: share across all projects
export const coreLayer = `You are an expert TypeScript developer.`;

// Every project uses the same version

Related Concepts

Conclusion

Layered prompts transform prompt management from monolithic chaos to composable architecture:

Key Benefits:

  1. Independent versioning – Update layers without affecting others
  2. Reusability – Share core/domain layers across projects
  3. A/B testing – Swap layers to test different approaches
  4. Caching integration – Outer layers cache for 90% cost reduction
  5. Maintainability – Clear separation of concerns

Implementation Steps:

  1. Extract universal principles → Core layer
  2. Extract project patterns → Domain layer
  3. Extract feature context → Application layer
  4. Keep task-specific details → Task layer
  5. Combine with cache control for maximum efficiency

The result is a prompt architecture that scales with your complexity while remaining maintainable and cost-effective.

References

Topics
AbstractionLlm WorkflowsMaintainabilityOnion ArchitecturePrompt ArchitecturePrompt EngineeringPrompt LayeringPrompt ReusabilitySeparation Of Concerns

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