Dependency Injection Guide
April 3, 2026 · View on GitHub
Adding services, registration patterns, and mocking strategies for Copilot Orchestrator
Overview
Copilot Orchestrator uses a Symbol-based dependency injection container with 36 tokens to manage service lifecycle, enable testability, and provide clean separation of concerns.
classDiagram
class ServiceContainer {
+registerSingleton(token, factory) void
+register(token, factory) void
+resolve(token) T
+createScope() ServiceContainer
}
class IConfigProvider {
<<interface>>
+getConfig(section, key, default) T
}
class IDialogService {
<<interface>>
+showInfo(msg) void
+showError(msg) void
}
class IGitOperations {
<<interface>>
+branches: IGitBranches
+worktrees: IGitWorktrees
+merge: IGitMerge
}
class IProcessSpawner {
<<interface>>
+spawn(cmd, args, opts) ChildProcess
}
class ILogger {
<<interface>>
+info(msg, ctx) void
+error(msg, ctx) void
+debug(msg, ctx) void
}
class VsCodeConfigProvider
class VsCodeDialogService
class DefaultGitOperations
class DefaultProcessSpawner
class Logger
IConfigProvider <|.. VsCodeConfigProvider
IDialogService <|.. VsCodeDialogService
IGitOperations <|.. DefaultGitOperations
IProcessSpawner <|.. DefaultProcessSpawner
ILogger <|.. Logger
ServiceContainer --> IConfigProvider
ServiceContainer --> IDialogService
ServiceContainer --> IGitOperations
ServiceContainer --> IProcessSpawner
ServiceContainer --> ILogger
Table of Contents
- Container Architecture
- Service Registration
- Interface Design
- Adding New Services
- Testing with DI
- Composition Roots
- Advanced Patterns
- Best Practices
Container Architecture
Core Components
| Component | File | Purpose |
|---|---|---|
| ServiceContainer | src/core/container.ts | Type-safe DI container with Symbol tokens |
| Tokens | src/core/tokens.ts | Symbol-based service registration keys |
| Production Root | src/composition.ts | Wires real implementations for production |
| Test Root | src/test/helpers/compositionTest.ts | Wires mock implementations for testing |
Container Features
// Symbol-based type safety
const container = new ServiceContainer();
// Singleton registration (cached after first resolve)
container.registerSingleton(Tokens.ILogger, (c) => new Logger());
// Transient registration (new instance each time)
container.register(Tokens.IValidator, (c) => new DataValidator());
// Scoped containers (inherit parent registrations)
const childScope = container.createScope();
// Dependency resolution with automatic injection
const logger = container.resolve<ILogger>(Tokens.ILogger);
Service Registration
Registration Types
Singleton Services
Use for stateful services that should be shared across the application:
// Logger - maintains state and OutputChannel
container.registerSingleton<ILogger>(Tokens.ILogger, (c) => {
const logger = Logger.initialize(context);
const configProvider = c.resolve<IConfigProvider>(Tokens.IConfigProvider);
logger.setConfigProvider(configProvider);
return logger;
});
// Process Monitor - caches process snapshots
container.registerSingleton<IProcessMonitor>(
Tokens.IProcessMonitor,
() => new ProcessMonitor()
);
Transient Services
Use for stateless services or when you need fresh instances:
// Evidence Validator - stateless validation logic
container.register<IEvidenceValidator>(
Tokens.IEvidenceValidator,
() => new DefaultEvidenceValidator()
);
Service Factory Patterns
Simple Factory
For services with no dependencies:
container.register<IFileSystem>(
Tokens.IFileSystem,
() => new NodeFileSystem()
);
Container-based Factory
For services that depend on other services:
container.register<IGitOperations>(Tokens.IGitOperations, (c) => {
const logger = c.resolve<ILogger>(Tokens.ILogger);
const processMonitor = c.resolve<IProcessMonitor>(Tokens.IProcessMonitor);
return new GitOperationsImpl(logger, processMonitor);
});
Configuration-based Factory
For services that need VS Code context or configuration:
container.registerSingleton<IConfigProvider>(
Tokens.IConfigProvider,
() => new VsCodeConfigProvider()
);
container.registerSingleton<IDialogService>(
Tokens.IDialogService,
() => new VsCodeDialogService()
);
Interface Design
Interface Conventions
Naming
- Prefix interfaces with
I:ILogger,IConfigProvider - Use descriptive names that indicate capability:
IProcessMonitor,IEvidenceValidator - Group related operations:
IGitOperationscontainsbranches,worktrees,merge
Structure
/**
* @fileoverview Interface for [capability description].
*
* [Brief description of what this abstraction provides and why it exists]
*
* @module interfaces/IMyService
*/
/**
* Interface for [specific capability].
*
* [More detailed description, usage patterns, and examples]
*
* @example
* ```typescript
* class MyComponent {
* constructor(private readonly service: IMyService) {}
*
* async doWork(): Promise<void> {
* await this.service.performOperation();
* }
* }
* ```
*/
export interface IMyService {
/**
* [Method description].
*
* @param param - Parameter description
* @returns Description of return value
*/
performOperation(param: string): Promise<boolean>;
}
Granularity Guidelines
Fine-grained Interfaces
For specific capabilities that can be mocked independently:
interface IConfigProvider {
getConfig<T>(section: string, key: string, defaultValue: T): T;
}
interface IDialogService {
showInfo(message: string): Promise<void>;
showError(message: string): Promise<void>;
showWarning(message: string, options?: any, ...actions: string[]): Promise<string | undefined>;
}
Composed Interfaces
For complex operations that logically belong together:
interface IGitOperations {
readonly branches: IGitBranches;
readonly worktrees: IGitWorktrees;
readonly merge: IGitMerge;
readonly repository: IGitRepository;
readonly executor: IGitExecutor;
}
Adding New Services
Step 1: Define the Interface
Create the interface file in src/interfaces/:
// src/interfaces/IMyNewService.ts
/**
* @fileoverview Interface for my new service capability.
*/
export interface IMyNewService {
/**
* Primary service operation.
*/
processData(input: string): Promise<ProcessResult>;
/**
* Service configuration.
*/
configure(options: ServiceOptions): void;
/**
* Health check for monitoring.
*/
isHealthy(): boolean;
}
export interface ProcessResult {
success: boolean;
data?: any;
error?: string;
}
export interface ServiceOptions {
timeout: number;
retryCount: number;
}
Step 2: Add Token
Register a Symbol token in src/core/tokens.ts:
/**
* Token for IMyNewService service.
* Provides [description of capability].
*/
export const IMyNewService = Symbol('IMyNewService');
Step 3: Create Implementation
Implement the interface in the appropriate module:
// src/myModule/myNewService.ts
import type { IMyNewService, ProcessResult, ServiceOptions } from '../interfaces';
import type { ILogger, IConfigProvider } from '../interfaces';
import { Logger } from '../core/logger';
export class MyNewServiceImpl implements IMyNewService {
private readonly logger: ILogger;
private options: ServiceOptions;
constructor(
private readonly configProvider: IConfigProvider,
logger?: ILogger
) {
this.logger = logger || Logger.for('my-service');
this.options = this.loadDefaultOptions();
}
async processData(input: string): Promise<ProcessResult> {
this.logger.info('Processing data', { inputLength: input.length });
try {
// Implementation logic here
const result = await this.performProcessing(input);
this.logger.debug('Processing completed', result);
return { success: true, data: result };
} catch (error) {
this.logger.error('Processing failed', error);
return { success: false, error: error.message };
}
}
configure(options: ServiceOptions): void {
this.options = { ...this.options, ...options };
this.logger.info('Service configured', options);
}
isHealthy(): boolean {
// Health check logic
return true;
}
private loadDefaultOptions(): ServiceOptions {
return {
timeout: this.configProvider.getConfig('myExtension', 'timeout', 5000),
retryCount: this.configProvider.getConfig('myExtension', 'retries', 3)
};
}
private async performProcessing(input: string): Promise<any> {
// Core processing logic
return { processed: input.toUpperCase() };
}
}
Step 4: Register in Production
Add registration in src/composition.ts:
// Add import
import { MyNewServiceImpl } from './myModule/myNewService';
export function createContainer(context: vscode.ExtensionContext): ServiceContainer {
const container = new ServiceContainer();
// ... existing registrations ...
// Register new service
container.registerSingleton<IMyNewService>(
Tokens.IMyNewService,
(c) => {
const configProvider = c.resolve<IConfigProvider>(Tokens.IConfigProvider);
const logger = c.resolve<Logger>(Tokens.ILogger);
return new MyNewServiceImpl(configProvider, logger.for('my-service'));
}
);
return container;
}
Real-World Example: PR Lifecycle Manager
The PR lifecycle manager demonstrates complex dependency injection with multiple service dependencies:
// src/composition.ts
container.registerSingleton<IManagedPRStore>(
Tokens.IManagedPRStore,
(c) => {
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
const fileSystem = c.resolve<IFileSystem>(Tokens.IFileSystem);
const { FileSystemManagedPRStore } = require('./plan/store/managedPRStore');
return new FileSystemManagedPRStore(workspacePath, fileSystem);
}
);
container.registerSingleton<IPRLifecycleManager>(
Tokens.IPRLifecycleManager,
(c) => {
const prServiceFactory = c.resolve<IRemotePRServiceFactory>(Tokens.IRemotePRServiceFactory);
const prMonitor = c.resolve<IReleasePRMonitor>(Tokens.IReleasePRMonitor);
const isolatedRepos = c.resolve<IIsolatedRepoManager>(Tokens.IIsolatedRepoManager);
const store = c.resolve<IManagedPRStore>(Tokens.IManagedPRStore);
const releaseConfig = c.resolve<IReleaseConfigManager>(Tokens.IReleaseConfigManager);
const { DefaultPRLifecycleManager } = require('./plan/prLifecycleManager');
return new DefaultPRLifecycleManager(prServiceFactory, prMonitor, isolatedRepos, store, releaseConfig);
}
);
Key characteristics:
- Storage layer (
IManagedPRStore) registered first with file system dependency - Manager layer (
IPRLifecycleManager) depends on 5 other services:IRemotePRServiceFactory— Provider-agnostic PR operationsIReleasePRMonitor— Autonomous monitoring and feedback handlingIIsolatedRepoManager— Isolated clone management for PR workIManagedPRStore— State persistenceIReleaseConfigManager— Configuration access
- Event emitter — Manager extends
EventEmitterfor UI synchronization - Provider-agnostic — Works with GitHub, GitHub Enterprise, and Azure DevOps via factory pattern
Step 5: Create Mock for Testing
Add mock implementation in src/test/helpers/testAdapters.ts:
export class MockMyNewService implements IMyNewService {
private calls: Array<{ method: string; args: any[] }> = [];
private responses: Map<string, any> = new Map();
private health = true;
async processData(input: string): Promise<ProcessResult> {
this.calls.push({ method: 'processData', args: [input] });
const response = this.responses.get('processData') || { success: true, data: { processed: input } };
return response;
}
configure(options: ServiceOptions): void {
this.calls.push({ method: 'configure', args: [options] });
}
isHealthy(): boolean {
this.calls.push({ method: 'isHealthy', args: [] });
return this.health;
}
// Test helper methods
setProcessDataResponse(response: ProcessResult): void {
this.responses.set('processData', response);
}
setHealth(healthy: boolean): void {
this.health = healthy;
}
getCalls(): Array<{ method: string; args: any[] }> {
return [...this.calls];
}
reset(): void {
this.calls = [];
this.responses.clear();
this.health = true;
}
}
Step 6: Register Mock in Test Root
Add to src/test/helpers/compositionTest.ts:
import { MockMyNewService } from '../test/helpers/testAdapters';
export function createTestContainer(): ServiceContainer {
const container = new ServiceContainer();
// ... existing mock registrations ...
// Register mock service
container.registerSingleton<IMyNewService>(
Tokens.IMyNewService,
() => new MockMyNewService()
);
return container;
}
Testing with DI
Test Setup Pattern
import { createTestContainer } from '../../test/helpers/compositionTest';
import * as Tokens from '../../../src/core/tokens';
import type { MockMyNewService } from '../../test/helpers/testAdapters';
suite('MyComponent Tests', () => {
let container: ServiceContainer;
let mockService: MockMyNewService;
let component: MyComponent;
setup(() => {
container = createTestContainer();
mockService = container.resolve(Tokens.IMyNewService) as MockMyNewService;
// Additional setup if needed
mockService.reset();
component = new MyComponent(
container.resolve(Tokens.IMyNewService),
container.resolve(Tokens.ILogger)
);
});
test('should process data successfully', async () => {
// Arrange
const input = 'test data';
const expectedResult = { success: true, data: { processed: 'TEST DATA' } };
mockService.setProcessDataResponse(expectedResult);
// Act
const result = await component.handleInput(input);
// Assert
assert.equal(result.success, true);
const calls = mockService.getCalls();
assert.equal(calls.length, 1);
assert.equal(calls[0].method, 'processData');
assert.deepEqual(calls[0].args, [input]);
});
});
Mock Verification Patterns
Call Tracking
test('should call service with correct parameters', () => {
component.initialize({ timeout: 1000 });
const calls = mockService.getCalls();
const configCall = calls.find(c => c.method === 'configure');
assert(configCall, 'Should call configure');
assert.equal(configCall.args[0].timeout, 1000);
});
Response Mocking
test('should handle service errors gracefully', async () => {
mockService.setProcessDataResponse({
success: false,
error: 'Processing failed'
});
const result = await component.handleInput('test');
assert.equal(result.success, false);
assert.match(result.error, /processing failed/i);
});
State Verification
test('should check service health', () => {
mockService.setHealth(false);
const status = component.getStatus();
assert.equal(status.healthy, false);
});
Composition Roots
Production Composition (src/composition.ts)
The production composition root wires real implementations:
export function createContainer(context: vscode.ExtensionContext): ServiceContainer {
const container = new ServiceContainer();
// ── VS Code Adapter Services ──
// Abstracts VS Code APIs for testability
container.registerSingleton<IConfigProvider>(/*...*/);
container.registerSingleton<IDialogService>(/*...*/);
container.registerSingleton<IClipboardService>(/*...*/);
// ── Infrastructure Services ──
// Core system capabilities
container.registerSingleton<IProcessMonitor>(/*...*/);
container.registerSingleton<ILogger>(/*...*/);
// ── Domain Services ──
// Business logic implementations
container.register<IEvidenceValidator>(/*...*/);
container.register<IGitOperations>(/*...*/);
return container;
}
Test Composition (src/test/helpers/compositionTest.ts)
The test composition root wires mock implementations:
export function createTestContainer(): ServiceContainer {
const container = new ServiceContainer();
// ── Mock Adapters ──
// Controllable test doubles
container.registerSingleton<IConfigProvider>(
Tokens.IConfigProvider,
() => new MockConfigProvider()
);
container.registerSingleton<IDialogService>(
Tokens.IDialogService,
() => new MockDialogService()
);
// ── Test Helpers ──
// Special test-only services
container.registerSingleton<ILogger>(
Tokens.ILogger,
() => new TestLogger()
);
return container;
}
Composition Guidelines
- Single Responsibility: Each composition root handles one environment
- Complete Coverage: Every interface must be registered
- Consistent Patterns: Use similar registration patterns across services
- Documentation: Comment complex factory functions
- Validation: Verify all dependencies can be resolved
Advanced Patterns
Scoped Containers
Use child containers for isolated subsystems:
class PlanRunner {
private planContainers = new Map<string, ServiceContainer>();
private createPlanScope(planId: string): ServiceContainer {
const planContainer = this.container.createScope();
// Register plan-specific services
planContainer.register<IPlanContext>(
Tokens.IPlanContext,
() => new PlanContext(planId)
);
this.planContainers.set(planId, planContainer);
return planContainer;
}
private cleanupPlanScope(planId: string): void {
this.planContainers.delete(planId);
}
}
Conditional Registration
Register different implementations based on environment:
export function createContainer(context: vscode.ExtensionContext): ServiceContainer {
const container = new ServiceContainer();
// Use different git implementation based on availability
if (isGitAvailable()) {
container.register<IGitOperations>(
Tokens.IGitOperations,
() => new RealGitOperations()
);
} else {
container.register<IGitOperations>(
Tokens.IGitOperations,
() => new FallbackGitOperations()
);
}
return container;
}
Lazy Initialization
Defer expensive service creation until needed:
container.registerSingleton<IExpensiveService>(
Tokens.IExpensiveService,
(c) => {
return new LazyWrapper(() => {
const config = c.resolve<IConfigProvider>(Tokens.IConfigProvider);
return new ExpensiveServiceImpl(config);
});
}
);
Service Decorators
Wrap services with additional behavior:
container.register<ILogger>(
Tokens.ILogger,
(c) => {
const baseLogger = new BaseLogger();
const configProvider = c.resolve<IConfigProvider>(Tokens.IConfigProvider);
if (configProvider.getConfig('app', 'enableMetrics', false)) {
return new MetricsLoggingDecorator(baseLogger);
}
return baseLogger;
}
);
Token Reference
Available DI tokens for service resolution:
| Token | Interface | Description |
|---|---|---|
IPlanRepository | IPlanRepository | High-level plan lifecycle management |
IPlanRepositoryStore | IPlanRepositoryStore | Low-level plan storage operations |
IPlanDefinition | IPlanDefinition | Lazy work spec access for plan nodes |
IPlanArchiver | IPlanArchiver | Plan archiving and cleanup |
IPlanRecovery | IPlanRecovery | Plan recovery from canceled/failed state |
ILogger | ILogger | Application logging service |
IGitOperations | IGitOperations | Git repository operations |
IProcessMonitor | IProcessMonitor | Process lifecycle monitoring |
IConfigProvider | IConfigProvider | VS Code configuration access |
IDialogService | IDialogService | VS Code dialog interactions |
IClipboardService | IClipboardService | System clipboard operations |
IPulseEmitter | IPulseEmitter | Realtime pulse/heartbeat events |
IProcessSpawner | IProcessSpawner | Child process management |
ICopilotRunner | ICopilotRunner | Copilot CLI execution |
IEnvironment | IEnvironment | System environment access |
IGlobalCapacity | IGlobalCapacity | Cross-extension resource coordination |
IPlanConfigManager | IPlanConfigManager | Plan configuration management |
IEvidenceValidator | IEvidenceValidator | Work completion validation |
IMcpManager | IMcpManager | MCP server lifecycle |
INodeRunner | INodeRunner | Individual node execution |
INodeExecutor | INodeExecutor | Node phase execution |
INodeStateMachine | INodeStateMachine | Node state transitions |
IFileSystem | IFileSystem | Filesystem operations |
IGitignoreDebouncer | IGitignoreDebouncer | Branch-change-aware .gitignore write delay |
IBulkPlanActions | IBulkPlanActions | Bulk plan operations (multi-select actions) |
IReleaseManager | IReleaseManager | Release lifecycle orchestration |
IReleasePRMonitor | IReleasePRMonitor | PR monitoring and feedback addressing |
IIsolatedRepoManager | IIsolatedRepoManager | Isolated repository clone management |
IReleaseStore | IReleaseStore | Release state persistence |
IRemotePRService | IRemotePRService | Provider-agnostic PR operations |
IRemoteProviderDetector | IRemoteProviderDetector | Remote provider detection and credential acquisition |
IRemotePRServiceFactory | IRemotePRServiceFactory | PR service factory with auto-detection |
IReleaseConfigManager | IReleaseConfigManager | Release configuration management |
IPRLifecycleManager | IPRLifecycleManager | PR lifecycle management and autonomous monitoring |
IManagedPRStore | IManagedPRStore | Managed PR state persistence |
Best Practices
Interface Design
- Single Purpose: Each interface should have one clear responsibility
- Stable Contracts: Interfaces should change infrequently
- Async by Default: Use Promise returns for IO operations
- Error Handling: Define clear error handling patterns
- Documentation: Every public method needs JSDoc
Registration Patterns
- Consistent Lifecycle: Be deliberate about singleton vs transient
- Minimal Dependencies: Keep factory functions simple
- Fail Fast: Register dependencies that can't be resolved
- Test Coverage: Every service should be mockable
- Performance: Avoid expensive operations in factories
Testing Strategy
- Mock External Boundaries: File system, network, VS Code APIs
- Test Real Logic: Don't mock your own domain objects
- Verify Interactions: Use call tracking for important collaborations
- Reset State: Clean up mocks between tests
- Integration Points: Test composition roots separately
Error Handling
- Graceful Degradation: Services should handle missing dependencies
- Clear Messages: Error messages should indicate the service and issue
- Logging: Log service registration and resolution failures
- Recovery: Provide fallback implementations when possible
- Validation: Validate service contracts at registration time
Performance Considerations
- Lazy Registration: Defer expensive services until needed
- Singleton Sharing: Share expensive resources across components
- Memory Management: Clean up resources in long-running services
- Circular Dependencies: Design interfaces to avoid circular references
- Container Overhead: Minimize container traversal in hot paths
This guide provides comprehensive coverage of dependency injection patterns, service registration, and testing strategies used throughout Copilot Orchestrator. For specific implementations, refer to the examples in src/composition.ts, src/test/helpers/compositionTest.ts, and the mock adapters in src/test/helpers/testAdapters.ts.