Advanced DI Patterns & Recipes

Aurelia's dependency injection system is powerful yet lightweight. This guide explores advanced patterns verified against the framework's codebase, showing you how to leverage DI for sophisticated application architecture.

Prerequisites

Before diving into advanced patterns, ensure you're familiar with:

Table of Contents

Interface-Based DI

Creating Interfaces with DI.createInterface

Interfaces allow you to inject by contract rather than concrete implementation, enabling better testability and flexibility.

import { DI } from '@aurelia/kernel';

// Define the contract
export interface ILogger {
  info(message: string): void;
  error(message: string, error?: Error): void;
}

// Create the injectable interface
export const ILogger = DI.createInterface<ILogger>('ILogger');

Default Registration

Provide a default implementation directly in the interface:

export class ConsoleLogger implements ILogger {
  info(message: string): void {
    console.log(`[INFO] ${message}`);
  }

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

// Interface with default singleton registration
export const ILogger = DI.createInterface<ILogger>(
  'ILogger',
  x => x.singleton(ConsoleLogger)
);

Now any component can inject ILogger without explicit registration:

import { resolve } from '@aurelia/kernel';
import { ILogger } from './services/logger';

export class UserService {
  private logger = resolve(ILogger); // Automatically gets ConsoleLogger

  async createUser(name: string) {
    this.logger.info(`Creating user: ${name}`);
    // ...
  }
}

Override Default Implementation

Replace the default when needed:

import { Registration } from '@aurelia/kernel';

export class FileLogger implements ILogger {
  info(message: string): void {
    // Write to file...
  }

  error(message: string, error?: Error): void {
    // Write to file...
  }
}

// In main.ts
Aurelia
  .register(
    Registration.singleton(ILogger, FileLogger) // Override default
  )
  .app(component)
  .start();

Real Example: Fetch Function Interface

From @aurelia/fetch-client:

export const IFetchFn = DI.createInterface<typeof fetch>('fetch', x => {
  if (typeof fetch !== 'function') {
    throw new Error('fetch function not found');
  }
  return x.instance(fetch); // Register global fetch as default
});

// Usage in HttpClient
export class HttpClient {
  private readonly fetchFn = resolve(IFetchFn);

  async request(url: string, options?: RequestInit) {
    const response = await this.fetchFn(url, options);
    return response;
  }
}

Aliasing Interfaces

export const IHttpClient = DI.createInterface<IHttpClient>(
  'IHttpClient',
  x => x.aliasTo(HttpClient) // Resolve to HttpClient class
);

Registration Patterns

Aurelia provides several registration helpers for different lifecycle needs.

Registration.instance

Register a pre-created instance:

import { Registration } from '@aurelia/kernel';

const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000
};

// Register the exact instance
container.register(
  Registration.instance('AppConfig', config)
);

// Usage
export class ApiClient {
  private config = resolve('AppConfig');

  async fetch(endpoint: string) {
    const url = `${this.config.apiUrl}${endpoint}`;
    // ...
  }
}

Registration.singleton

Create and cache one instance per container:

export class DatabaseConnection {
  private connection: any;

  async connect() {
    this.connection = await openConnection();
  }
}

container.register(
  Registration.singleton(DatabaseConnection, DatabaseConnection)
);

Registration.transient

Create a new instance on every resolution:

export class RequestContext {
  readonly id = crypto.randomUUID();
  readonly timestamp = Date.now();
}

container.register(
  Registration.transient(RequestContext, RequestContext)
);

// Each resolution gets a new instance
const ctx1 = container.get(RequestContext); // New instance
const ctx2 = container.get(RequestContext); // Different instance

Registration.callback

Execute a function each time the key is resolved:

import { Registration } from '@aurelia/kernel';

// Random number generator
container.register(
  Registration.callback('random', () => Math.random())
);

// Dynamic service creation
container.register(
  Registration.callback('timestamp', () => Date.now())
);

// Access container in callback
container.register(
  Registration.callback('logger', (c: IContainer) => {
    const config = c.get('AppConfig');
    return config.debug ? new VerboseLogger() : new QuietLogger();
  })
);

Registration.cachedCallback

Execute callback once, then cache the result:

import { Registration } from '@aurelia/kernel';

let initCount = 0;

container.register(
  Registration.cachedCallback('expensive', () => {
    initCount++;
    return performExpensiveComputation();
  })
);

container.get('expensive'); // Runs computation, initCount = 1
container.get('expensive'); // Returns cached result, initCount still 1
container.get('expensive'); // Returns cached result, initCount still 1

Real-world example from @aurelia/router:

export const IBaseHref = DI.createInterface<URL>('IBaseHref');

// In configuration
Registration.cachedCallback(IBaseHref, (handler) => {
  const baseElement = document.querySelector('base');
  const href = baseElement?.getAttribute('href') ?? '/';
  return new URL(href, window.location.origin);
})

Registration.aliasTo

Create multiple keys that resolve to the same instance:

export class UserRepository {
  // ...
}

container.register(
  Registration.singleton(UserRepository, UserRepository),
  Registration.aliasTo(UserRepository, 'IUserRepo'),
  Registration.aliasTo(UserRepository, 'UserStore')
);

// All resolve to the same singleton instance
const repo1 = container.get(UserRepository);
const repo2 = container.get('IUserRepo');
const repo3 = container.get('UserStore');
// repo1 === repo2 === repo3 → true

Resolver Patterns

Resolvers modify how dependencies are injected, enabling advanced patterns.

lazy: Deferred Resolution

Inject a function that resolves the dependency when called:

import { lazy, resolve } from '@aurelia/kernel';

export class FeatureToggle {
  // Expensive service only created if feature is enabled
  private getAnalytics = resolve(lazy(IAnalyticsService));

  trackEvent(name: string) {
    if (this.isEnabled('analytics')) {
      const analytics = this.getAnalytics(); // Resolved now
      analytics.track(name);
    }
  }
}

Type-safe usage:

import { ILazyResolver } from '@aurelia/kernel';

export class LazyExample {
  // Explicitly typed lazy resolver
  private getService = resolve<ILazyResolver<IMyService>>(lazy(IMyService));

  doSomething() {
    const service = this.getService(); // Returns IMyService
    service.execute();
  }
}

optional: Graceful Fallbacks

Inject undefined if dependency not registered:

import { optional, resolve } from '@aurelia/kernel';

export class OptionalFeatures {
  // May not be registered in all environments
  private metrics = resolve(optional(IMetricsService));

  recordMetric(name: string, value: number) {
    // Safe to check for undefined
    if (this.metrics) {
      this.metrics.record(name, value);
    }
  }
}

all: Collect All Registrations

Resolve all registered instances of a key:

import { all, resolve } from '@aurelia/kernel';

export interface IPlugin {
  initialize(): void;
}

export const IPlugin = DI.createInterface<IPlugin>('IPlugin');

// Register multiple plugins
container.register(
  Registration.singleton(IPlugin, AuthPlugin),
  Registration.singleton(IPlugin, LoggingPlugin),
  Registration.singleton(IPlugin, CachePlugin)
);

export class PluginManager {
  private plugins = resolve(all(IPlugin)); // Array of all plugins

  initializeAll() {
    this.plugins.forEach(plugin => plugin.initialize());
  }
}

With searchAncestors:

// Search parent containers too
private allLoggers = resolve(all(ILogger, true));

factory: Dynamic Instance Creation

Inject a factory function that creates new instances:

import { factory, resolve } from '@aurelia/kernel';

export class CommandProcessor {
  // Factory for creating command instances
  private createCommand = resolve(factory(Command));

  execute(type: string, ...args: unknown[]) {
    // Create new instance with dynamic dependencies
    const command = this.createCommand(type, ...args);
    return command.run();
  }
}

Type-safe factory:

import { IFactoryResolver } from '@aurelia/kernel';

export class TaskRunner {
  private createTask = resolve<IFactoryResolver<ITask>>(factory(ITask));

  runTask(data: TaskData) {
    // createTask returns ITask instances
    const task = this.createTask(data);
    return task.execute();
  }
}

Real example from @aurelia/fetch-client:

export class HttpClient {
  private readonly createConfiguration = resolve(factory(HttpClientConfiguration));

  configure(configFn: (config: HttpClientConfiguration) => void) {
    // Create fresh configuration instance
    const config = this.createConfiguration();
    configFn(config);
    return this;
  }
}

newInstanceOf: Always New Instance

Create a new instance every time, ignoring registration lifecycle:

import { newInstanceOf, resolve } from '@aurelia/kernel';

export class RequestHandler {
  // Always get a fresh context, even if registered as singleton
  private context = resolve(newInstanceOf(RequestContext));

  handle(request: Request) {
    // Each resolution gets a new RequestContext
    console.log(this.context.id); // Unique each time
  }
}

newInstanceForScope: Scoped Instances

Create one instance per requesting container and register it there:

import { newInstanceForScope, resolve } from '@aurelia/kernel';
import { IValidationController } from '@aurelia/validation';

export class UserForm {
  // Each component gets its own validation controller
  // Registered in component's scope
  private validation = resolve(newInstanceForScope(IValidationController));

  async submit() {
    const result = await this.validation.validate();
    if (result.valid) {
      // ...
    }
  }
}

Why use newInstanceForScope?

  • Validation controllers are scoped to components

  • Each form gets its own controller

  • Child components can inject the same controller

  • Automatic cleanup when component is disposed

// Real example from Aurelia's e2e tests
export class EditView {
  private validationController = resolve(newInstanceForScope(IValidationController));

  // Child components can also access this controller
}

own: Container-Local Resolution

Only resolve if the dependency is registered in the requesting container (not ancestors):

import { own, resolve } from '@aurelia/kernel';

export class ScopedService {
  // Only resolve if registered in this exact container
  private localConfig = resolve(own('LocalConfig'));

  // Returns undefined if not in this container
}

resource & optionalResource: Smart Resolution

Resolve from requestor or root, skipping intermediate containers:

import { resource, optionalResource, resolve } from '@aurelia/kernel';

export class ComponentBase {
  // Check requestor first, fallback to root, skip middle layers
  private theme = resolve(resource(ITheme));

  // Optional variant
  private customTheme = resolve(optionalResource(ICustomTheme));
}

Factory Patterns

Service Factory Pattern

Create services dynamically based on configuration:

import { DI, IContainer, Registration } from '@aurelia/kernel';

export interface IDataService {
  fetchData(): Promise<any[]>;
}

export const IDataService = DI.createInterface<IDataService>('IDataService');

export class RestDataService implements IDataService {
  async fetchData() {
    // REST API implementation
  }
}

export class GraphQLDataService implements IDataService {
  async fetchData() {
    // GraphQL implementation
  }
}

// Factory function
export const createDataService = (type: 'rest' | 'graphql'): IDataService => {
  return type === 'rest' ? new RestDataService() : new GraphQLDataService();
};

// Register factory
container.register(
  Registration.callback(IDataService, (c: IContainer) => {
    const config = c.get('ApiConfig');
    return createDataService(config.type);
  })
);

Plugin Factory Pattern

Dynamically register and create plugins:

export interface IPluginFactory {
  create(name: string, options: unknown): IPlugin;
  register(name: string, pluginClass: Constructable<IPlugin>): void;
}

export const IPluginFactory = DI.createInterface<IPluginFactory>('IPluginFactory');

export class PluginFactory implements IPluginFactory {
  private registry = new Map<string, Constructable<IPlugin>>();
  private container = resolve(IContainer);

  register(name: string, pluginClass: Constructable<IPlugin>): void {
    this.registry.set(name, pluginClass);
  }

  create(name: string, options: unknown): IPlugin {
    const PluginClass = this.registry.get(name);
    if (!PluginClass) {
      throw new Error(`Plugin not found: ${name}`);
    }

    // Use container to construct with DI
    const plugin = this.container.invoke(PluginClass);
    if (typeof (plugin as any).configure === 'function') {
      (plugin as any).configure(options);
    }
    return plugin;
  }
}

Child Containers & Scoping

Child containers enable hierarchical dependency scoping—perfect for features, routes, or components with isolated dependencies.

Creating Child Containers

import { DI } from '@aurelia/kernel';

const rootContainer = DI.createContainer();

// Register global services
rootContainer.register(
  Registration.singleton(ILogger, ConsoleLogger)
);

// Create child with isolated scope
const childContainer = rootContainer.createChild();

// Override in child
childContainer.register(
  Registration.singleton(ILogger, FileLogger)
);

// Child uses FileLogger, root still uses ConsoleLogger
const childLogger = childContainer.get(ILogger); // FileLogger
const rootLogger = rootContainer.get(ILogger);   // ConsoleLogger

Inherit Parent Resources

const childContainer = parentContainer.createChild({
  inheritParentResources: true
});

Real example from @aurelia/router:

export class RouteContext {
  constructor(parentContainer: IContainer) {
    // Create isolated container for this route
    const container = this.container = parentContainer.createChild();

    // Register route-specific services
    container.register(
      Registration.instance(IRouteContext, this)
    );
  }
}

Scoped Service Pattern

export interface IRequestScope {
  requestId: string;
  user: User | null;
}

export const IRequestScope = DI.createInterface<IRequestScope>('IRequestScope');

export class RequestHandler {
  handleRequest(request: Request) {
    // Create request-scoped container
    const requestContainer = this.rootContainer.createChild();

    // Register request-specific data
    requestContainer.register(
      Registration.instance(IRequestScope, {
        requestId: crypto.randomUUID(),
        user: request.user
      })
    );

    // Process with scoped container
    const processor = requestContainer.get(RequestProcessor);
    return processor.process(request);
  }
}

export class RequestProcessor {
  private scope = resolve(IRequestScope); // Gets request-specific data

  process(request: Request) {
    console.log(`Processing request ${this.scope.requestId}`);
    // Access this.scope.user, etc.
  }
}

Feature Module Pattern

export class FeatureModule {
  static register(container: IContainer) {
    // Create feature-scoped container
    const featureContainer = container.createChild();

    // Register feature-specific services
    featureContainer.register(
      Registration.singleton(IFeatureService, FeatureService),
      Registration.singleton(IFeatureRepository, FeatureRepository)
    );

    return featureContainer;
  }
}

// Usage
const featureContainer = FeatureModule.register(rootContainer);
const service = featureContainer.get(IFeatureService);

Transformers

Transformers modify instances after construction—useful for decoration, proxying, or post-processing.

Registering Transformers

import { DI, Registration } from '@aurelia/kernel';

export class UserService {
  getUser(id: string) {
    return { id, name: 'John' };
  }
}

const container = DI.createContainer();
container.register(Registration.singleton(UserService, UserService));

// Add transformer
container.registerTransformer(UserService, (instance) => {
  // Wrap in logging proxy
  return new Proxy(instance, {
    get(target, prop) {
      const value = target[prop];
      if (typeof value === 'function') {
        return function(...args: unknown[]) {
          console.log(`Calling ${String(prop)}`, args);
          const result = value.apply(target, args);
          console.log(`Result:`, result);
          return result;
        };
      }
      return value;
    }
  });
});

const service = container.get(UserService);
service.getUser('123'); // Logs: "Calling getUser", "Result: ..."

Multiple Transformers

Transformers execute in registration order:

container.registerTransformer(UserService, (instance) => {
  console.log('Transform 1');
  return instance;
});

container.registerTransformer(UserService, (instance) => {
  console.log('Transform 2');
  return instance;
});

container.get(UserService);
// Output:
// Transform 1
// Transform 2

Real-World: Adding Lifecycle Hooks

interface IDisposable {
  dispose(): void;
}

function addDisposable<T>(instance: T): T & IDisposable {
  const disposables: Array<() => void> = [];

  return Object.assign(instance, {
    onDispose(fn: () => void) {
      disposables.push(fn);
    },
    dispose() {
      disposables.forEach(fn => fn());
      disposables.length = 0;
    }
  });
}

container.registerTransformer(DatabaseConnection, addDisposable);

const db = container.get(DatabaseConnection);
db.onDispose(() => console.log('Closing connection'));
db.dispose(); // Logs: "Closing connection"

Real-World Recipes

Recipe 1: Multi-Tenant Application

export interface ITenant {
  id: string;
  name: string;
  config: TenantConfig;
}

export const ITenant = DI.createInterface<ITenant>('ITenant');

export class TenantResolver {
  private rootContainer = resolve(IContainer);

  resolveForRequest(request: Request): IContainer {
    const tenantId = this.extractTenantId(request);
    const tenant = this.loadTenant(tenantId);

    // Create tenant-scoped container
    const tenantContainer = this.rootContainer.createChild();

    tenantContainer.register(
      Registration.instance(ITenant, tenant),
      Registration.singleton(ITenantDatabase, TenantDatabase),
      Registration.callback('DbConnection', () => {
        return createConnection(tenant.config.database);
      })
    );

    return tenantContainer;
  }

  private extractTenantId(request: Request): string {
    // From subdomain, header, etc.
    return request.headers.get('X-Tenant-ID') ?? 'default';
  }

  private loadTenant(id: string): ITenant {
    // Load from database
    return {
      id,
      name: `Tenant ${id}`,
      config: { database: `tenant_${id}_db` }
    };
  }
}

Recipe 2: Environment-Based Configuration

type Environment = 'development' | 'staging' | 'production';

export interface IApiConfig {
  baseUrl: string;
  timeout: number;
  retries: number;
}

export const IApiConfig = DI.createInterface<IApiConfig>('IApiConfig');

const configs: Record<Environment, IApiConfig> = {
  development: {
    baseUrl: 'http://localhost:3000',
    timeout: 10000,
    retries: 1
  },
  staging: {
    baseUrl: 'https://staging-api.example.com',
    timeout: 5000,
    retries: 2
  },
  production: {
    baseUrl: 'https://api.example.com',
    timeout: 3000,
    retries: 3
  }
};

// Register based on environment
const env = (process.env.NODE_ENV as Environment) ?? 'development';
container.register(
  Registration.instance(IApiConfig, configs[env])
);

Recipe 3: Plugin System with DI

export interface IPlugin {
  name: string;
  initialize(container: IContainer): void;
  shutdown?(): void;
}

export const IPlugin = DI.createInterface<IPlugin>('IPlugin');

export class PluginManager {
  private plugins = resolve(all(IPlugin));
  private container = resolve(IContainer);
  private initialized = false;

  async initialize() {
    if (this.initialized) return;

    for (const plugin of this.plugins) {
      console.log(`Initializing plugin: ${plugin.name}`);
      plugin.initialize(this.container);
    }

    this.initialized = true;
  }

  async shutdown() {
    for (const plugin of this.plugins.reverse()) {
      if (plugin.shutdown) {
        console.log(`Shutting down plugin: ${plugin.name}`);
        await plugin.shutdown();
      }
    }
  }
}

// Example plugins
export class AuthPlugin implements IPlugin {
  name = 'auth';

  initialize(container: IContainer) {
    container.register(
      Registration.singleton(IAuthService, AuthService)
    );
  }
}

export class CachePlugin implements IPlugin {
  name = 'cache';
  private cache?: CacheService;

  initialize(container: IContainer) {
    this.cache = new CacheService();
    container.register(
      Registration.instance(ICacheService, this.cache)
    );
  }

  shutdown() {
    this.cache?.clear();
  }
}

// Register plugins
container.register(
  Registration.singleton(IPlugin, AuthPlugin),
  Registration.singleton(IPlugin, CachePlugin),
  Registration.singleton(PluginManager, PluginManager)
);

// Initialize
const pm = container.get(PluginManager);
await pm.initialize();

Recipe 4: Decorator Pattern with Transformers

export interface INotificationService {
  send(message: string): Promise<void>;
}

export class NotificationService implements INotificationService {
  async send(message: string) {
    console.log(`Sending: ${message}`);
    // Send notification
  }
}

// Add retry logic via transformer
container.register(Registration.singleton(INotificationService, NotificationService));

container.registerTransformer(INotificationService, (service) => {
  return new Proxy(service, {
    get(target, prop) {
      if (prop === 'send') {
        return async (message: string) => {
          let attempts = 0;
          const maxAttempts = 3;

          while (attempts < maxAttempts) {
            try {
              return await target.send(message);
            } catch (error) {
              attempts++;
              if (attempts >= maxAttempts) throw error;
              await new Promise(resolve => setTimeout(resolve, 1000 * attempts));
            }
          }
        };
      }
      return target[prop];
    }
  });
});

Recipe 5: Context-Aware Services

export interface IUserContext {
  userId: string;
  permissions: string[];
}

export const IUserContext = DI.createInterface<IUserContext>('IUserContext');

export class PermissionGuard {
  private userContext = resolve(optional(IUserContext));

  canAccess(resource: string): boolean {
    if (!this.userContext) {
      return false; // No context = no access
    }

    return this.userContext.permissions.includes(resource);
  }
}

// In route handler
export class SecureRoute {
  private rootContainer = resolve(IContainer);

  async handle(request: Request) {
    const user = await this.authenticate(request);

    // Create request-scoped container with user context
    const requestContainer = this.rootContainer.createChild();

    requestContainer.register(
      Registration.instance(IUserContext, {
        userId: user.id,
        permissions: user.permissions
      })
    );

    // Process with user context available
    const controller = requestContainer.get(Controller);
    return controller.execute();
  }
}

Best Practices

  1. Prefer Interfaces: Use DI.createInterface for public contracts

  2. Use Appropriate Lifecycles: Singleton for stateless, transient for stateful

  3. Leverage Child Containers: Isolate feature/route dependencies

  4. Type Your Resolvers: Use resolve<IFactoryResolver<T>> for type safety

  5. Document Resolvers: Explain why you're using lazy, optional, etc.

  6. Clean Up: Dispose child containers when features unmount

  7. Test with Mocks: Use Registration.instance to inject test doubles

  8. Avoid Service Locator: Prefer constructor injection via resolve()

Common Pitfalls

Pitfall 1: Circular Dependencies

// BAD: Circular dependency
export class ServiceA {
  private b = resolve(ServiceB);
}

export class ServiceB {
  private a = resolve(ServiceA); // Circular!
}

// GOOD: Use lazy resolver
export class ServiceB {
  private getA = resolve(lazy(ServiceA));

  doSomething() {
    const a = this.getA(); // Resolved on-demand
    // ...
  }
}

Pitfall 2: Forgetting Child Container Disposal

// BAD: Memory leak
const childContainer = parent.createChild();
const service = childContainer.get(ExpensiveService);
// Container never disposed, service held in memory

// GOOD: Dispose when done
const childContainer = parent.createChild();
try {
  const service = childContainer.get(ExpensiveService);
  // Use service
} finally {
  childContainer.dispose();
}

Pitfall 3: Over-using Transformers

Transformers run on every resolution. Don't use them for expensive operations that should run once:

// BAD: Expensive operation runs every time
container.registerTransformer(Service, (instance) => {
  performExpensiveSetup(instance); // Runs on every get()
  return instance;
});

// GOOD: Use cachedCallback or singleton setup
export class Service {
  private setupDone = false;

  async ensureSetup() {
    if (!this.setupDone) {
      await this.performSetup();
      this.setupDone = true;
    }
  }
}

Conclusion

Aurelia's DI system provides powerful primitives for building scalable, maintainable applications. By mastering interfaces, registration patterns, resolvers, and child containers, you can architect sophisticated dependency graphs that remain testable and flexible.

Key takeaways:

  • Use interfaces for flexibility and testing

  • Choose the right registration lifecycle

  • Leverage resolvers for advanced scenarios

  • Scope dependencies with child containers

  • Apply transformers judiciously

For more information:

Last updated

Was this helpful?