DEV Community

Ryan
Ryan

Posted on

VSCode 1 - Service : Dependency Injection(DI)

In modern software development, particularly with large-scale applications like Visual Studio Code, managing dependencies between components becomes increasingly complex. VSCode's approach to this challenge is a robust dependency injection (DI) system with many services, centered around the registerSingleton function.

This article explores how VSCode's DI system works, why it's valuable, and how it compares to other approaches.

The Problem: Component Dependencies

In any large application, components need to work together. Consider a simple text editor:

  • The EditorView displays text and handles user input
  • The FileService loads and saves files
  • The ThemeService manages visual appearance

Without dependency injection, components might create their dependencies directly:

class EditorView {
  constructor() {
    // Directly creating dependencies
    this.fileService = new FileService();
    this.themeService = new ThemeService();
  }

  openFile(path) {
    const content = this.fileService.readFile(path);
    this.displayContent(content);
  }
}
Enter fullscreen mode Exit fullscreen mode

This approach leads to several problems:

  1. Tight coupling: The editor is permanently tied to specific implementations
  2. Testing difficulty: You can't easily substitute mock services for tests
  3. Flexibility loss: Changing implementations requires modifying multiple classes

VSCode's Solution: The registerSingleton Pattern

VSCode elegantly solves these problems with a system built around three core concepts:

  1. Service interfaces: Define what a service does, not how it does it
  2. Service identifiers: Unique tokens that represent services
  3. Service registration: The process of mapping interfaces to implementations

Here's a simplified example:

// 1. Define a service interface
interface IFileService {
  readFile(path: string): Promise<string>;
  writeFile(path: string, content: string): Promise<void>;
}

// 2. Create a service identifier
const IFileService = createDecorator<IFileService>('fileService');

// 3. Implement the service
class FileService implements IFileService {
  readFile(path: string): Promise<string> {
    // Implementation
    return fs.promises.readFile(path, 'utf8');
  }

  writeFile(path: string, content: string): Promise<void> {
    // Implementation
    return fs.promises.writeFile(path, content);
  }
}

// 4. Register the service
registerSingleton(IFileService, FileService, InstantiationType.Eager);
Enter fullscreen mode Exit fullscreen mode

When you call registerSingleton(IFileService, FileService, InstantiationType.Eager), you're telling VSCode:

  1. "When a component needs an IFileService..."
  2. "...create an instance of FileService..."
  3. "...and provide that instance to the component."

But notice, services are always lazily created, even if you register them as Eager.

The magic happens when components declare their dependencies like FileService in Typescript:

class EditorView {
  constructor(
    @IFileService private fileService: IFileService,
    @IThemeService private themeService: IThemeService
  ) {
    // Services are injected, not created
  }

  openFile(path) {
    const content = this.fileService.readFile(path);
    this.displayContent(content);
  }
}
Enter fullscreen mode Exit fullscreen mode

Visualizing the Dependency Injection Flow

Here's how the system works at runtime:

┌───────────────────┐     requests       ┌───────────────────────┐
│                   │ ---------------->  │                       │
│    EditorView     │                    │ InstantiationService  │
│                   │ <----------------  │                       │
└───────────────────┘   injects services └─────────┬─────────────┘
                                                   │
                                                   │ looks up
                                                   ▼
┌───────────────────┐                 ┌─────────────────────┐
│                   │                 │                     │
│    FileService    │ <-------------- │  Service Registry   │
│                   │    creates      │                     │
└───────────────────┘                 └─────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Benefits of VSCode's Approach

1. Decoupling Components

Components depend on interfaces, not implementations. This means:

  • The EditorView only knows about the IFileService interface
  • The concrete FileService can be replaced without changing consumers
  • Components focus on their own responsibilities

2. Simplified Testing

With DI, testing becomes straightforward:

// Create a mock service
const mockFileService: IFileService = {
  readFile: jest.fn().mockResolvedValue("test content"),
  writeFile: jest.fn().mockResolvedValue(undefined)
};

// Test with the mock
const editor = new EditorView(mockFileService, mockThemeService);
await editor.openFile("test.txt");

// Verify the mock was called
expect(mockFileService.readFile).toHaveBeenCalledWith("test.txt");
Enter fullscreen mode Exit fullscreen mode

3. Supporting Different Environments

VSCode runs on multiple platforms (desktop, web, remote). The DI system allows for platform-specific implementations:

if (platform === 'web') {
  registerSingleton(IFileService, WebFileService);
} else {
  registerSingleton(IFileService, DesktopFileService);
}
Enter fullscreen mode Exit fullscreen mode

Components using IFileService remain unchanged while getting environment-appropriate implementations.

4. Extension Ecosystem Support

For an extensible platform like VSCode, DI creates clear extension points:

// An extension can replace a standard service
context.registerServiceProvider(ISearchService, BetterSearchService);
Enter fullscreen mode Exit fullscreen mode

A Complete Example

Let's look at a more complete example showing the full pattern:

// 1. Service interface and identifier
interface ILogService {
  log(message: string): void;
  error(message: string, error?: Error): void;
}
const ILogService = createDecorator<ILogService>('logService');

// 2. Service implementation
class ConsoleLogService implements ILogService {
  log(message: string): void {
    console.log(`[LOG] ${message}`);
  }

  error(message: string, error?: Error): void {
    console.error(`[ERROR] ${message}`, error);
  }
}

// 3. Service registration
registerSingleton(ILogService, ConsoleLogService, InstantiationType.Eager);

// 4. Service consumption
class DocumentProcessor {
  constructor(
    @ILogService private readonly logService: ILogService,
    @IFileService private readonly fileService: IFileService
  ) {}

  async processDocument(path: string): Promise<void> {
    try {
      this.logService.log(`Processing document: ${path}`);
      const content = await this.fileService.readFile(path);
      // Process content...
      this.logService.log(`Document processed successfully`);
    } catch (e) {
      this.logService.error(`Failed to process document`, e);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

About Instantiation Types

VSCode's DI system offers control over when services are created:

// Created immediately at startup
registerSingleton(ILogService, ConsoleLogService, InstantiationType.Eager);

// Created when first requested (default)
registerSingleton(ISearchService, SearchService, InstantiationType.Delayed);
Enter fullscreen mode Exit fullscreen mode

This allows performance optimization by:

  • Loading critical services immediately
  • Deferring non-essential services until needed

Advanced 1: Comparison with Nest.js

If you're familiar with Nest.js, you'll notice striking similarities:

// Nest.js approach
@Injectable()
class LogService {
  log(message: string): void {
    console.log(`[LOG] ${message}`);
  }
}

@Controller()
class AppController {
  constructor(private readonly logService: LogService) {}

  @Get()
  handleRequest() {
    this.logService.log('Handling request');
    return 'Hello World';
  }
}
Enter fullscreen mode Exit fullscreen mode

Both systems use:

  • Decorator-based injection
  • Constructor injection
  • Singleton services by default
  • A centralized container that manages instances

The key difference is that VSCode's system is more focused on interface/implementation separation, while Nest.js often injects concrete classes directly.

Advanced 2: source code for implementations

createDecorator

export namespace _util {
  export const serviceIds = new Map<string, ServiceIdentifier<any>>();

  export const DI_TARGET = '$di$target';
  export const DI_DEPENDENCIES = '$di$dependencies';

  export function getServiceDependencies(ctor: any): { id: ServiceIdentifier<any>; index: number }[] {
    return ctor[DI_DEPENDENCIES] || [];
  }
}

export interface ServiceIdentifier<T> {
  (...args: any[]): void;
  type: T;
}

function storeServiceDependency(id: Function, target: Function, index: number): void {
  if ((target as any)[_util.DI_TARGET] === target) {
    (target as any)[_util.DI_DEPENDENCIES].push({ id, index });
  } else {
    (target as any)[_util.DI_DEPENDENCIES] = [{ id, index }];
    (target as any)[_util.DI_TARGET] = target;
  }
}

export function createDecorator<T>(serviceId: string): IServiceIdentifier<T> {
  if (_util.serviceIds.has(serviceId)) {
    return _util.serviceIds.get(serviceId)!;
  }

  const id = <any>function (target: Function, key: string, index: number) {
    storeServiceDependency(id, target, index);
  };

  id.toString = () => serviceId;

  _util.serviceIds.set(serviceId, id);
  return id;
}
Enter fullscreen mode Exit fullscreen mode

ServiceCollection

export class ServiceCollection {
  private _entries = new Map<ServiceIdentifier<any>, any>();

  constructor(...entries: [ServiceIdentifier<any>, any][]) {
    for (const [id, service] of entries) {
      this.set(id, service);
    }
  }

  set<T>(id: ServiceIdentifier<T>, instanceOrDescriptor: T | SyncDescriptor<T>): T | SyncDescriptor<T> {
    const result = this._entries.get(id);
    this._entries.set(id, instanceOrDescriptor);
    return result;
  }

  has(id: ServiceIdentifier<any>): boolean {
    return this._entries.has(id);
  }

  get<T>(id: ServiceIdentifier<T>): T | SyncDescriptor<T> {
    return this._entries.get(id);
  }
}
Enter fullscreen mode Exit fullscreen mode

registerSingleton(simply version)

const _globalServiceCollection = new ServiceCollection();

export const enum InstantiationType {
  Eager = 0,
  Delayed = 1
}

export function registerSingleton<T>(
  id: IServiceIdentifier<T>, 
  ctor: new (...args: any[]) => T, 
  instantiationType: InstantiationType = InstantiationType.Delayed
): void {
  // In actual VSCode, this would use an internal mechanism for serviceCollection instead of _globalServiceCollection
  if (instantiationType === InstantiationType.Eager) {
    // Create immediately
    const instance = new ctor();
    _globalServiceCollection.set(id, instance);
  } else {
    // Delay
    _globalServiceCollection.set(id, ctor);
  }
}
Enter fullscreen mode Exit fullscreen mode

InstantiationService(simple version)

export class InstantiationService {
  private _serviceCollection: ServiceCollection;

  constructor(serviceCollection: ServiceCollection) {
    this._serviceCollection = serviceCollection;
  }

  createInstance<T>(ctor: new (...args: any[]) => T, ...args: any[]): T {
    // Simplified implementation - in reality, VSCode does more complex dependency resolution
    return new ctor(...args);
  }

  getService<T>(serviceId: IServiceIdentifier<T>): T {
    // Get the service or its constructor
    const instanceOrCtor = this._serviceCollection.get(serviceId);

    // If it's already an instance, return it
    if (typeof instanceOrCtor !== 'function') {
      return instanceOrCtor;
    }

    // Otherwise, create an instance
    const instance = this.createInstance(instanceOrCtor);
    // Save the instance for future use (singleton pattern)
    this._serviceCollection.set(serviceId, instance);
    return instance;
  }
}
Enter fullscreen mode Exit fullscreen mode

LoggerService

interface ILoggerService {
  log(message: string): void;
}

const ILoggerService = createDecorator<ILoggerService>('loggerService');

class LoggerService implements ILoggerService {
  log(message: string): void {
    console.log(`[Logger] ${message}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

bootstrapApplication

function bootstrapApplication() {
  _globalServiceCollection = new ServiceCollection();

  registerSingleton(ILoggerService, LoggerService, InstantiationType.Delayed);

  const instantiationService = new InstantiationService(_globalServiceCollection);
  return instantiationService;
}

// Bootstrap the application
const instantiationService = bootstrapApplication();

// Get and use a service
const loggerService = instantiationService.getService(ILoggerService);
loggerService.log('Hello, world!');
Enter fullscreen mode Exit fullscreen mode

Conclusion

VSCode's dependency injection system, centered around registerSingleton, provides a clean solution to the challenge of component dependencies in large applications. By separating interfaces from implementations and centralizing service creation, it creates a more testable, flexible, and maintainable codebase.

The pattern has proven so effective that similar approaches appear in many modern frameworks, from Angular to Nest.js. Understanding this architectural pattern helps not just when working with VSCode's source code, but when designing any complex application with multiple interconnected components.

As you build your own applications, consider how this pattern might help manage the growing complexity of component relationships, particularly if you anticipate needs for testing, platform-specific implementations, or future extensibility.

Top comments (1)

Collapse
 
doston_yuldashev_9e2c7979 profile image
Doston Yuldashev

Hi Ryan,
Thanks for sharing, this was super helpful