Patterns for Custom Standalone APIs in Angular

Together with Standalone Components, the Angular team introduced Standalone APIs. They allow for setting up libraries in a more lightweight way. Examples of libraries currently providing Standalone APIs are the HttpClient and the Router. Also, NGRX is an early adopter of this idea.

In this article, I present several patterns for writing custom Standalone APIs inferred from the before mentioned libraries. For each pattern, the following aspects are discussed: intentions behind the pattern, description, example implementation, examples for occurrences in the mentioned libraries, and variations for implementation details.

Most of these patterns are especially interesting for library authors. They have the potential to improve the DX for the library's consumers. On the other side, most of them might be overkill for applications.

Big thanks to Angular's Alex Rickabaugh for proofreading and providing feedback!

📂 Source code used in examples

Example

For presenting the inferred patterns, a simple logger library is used. This library is as simple as possible but as complex as needed to demonstrate the implementation of the patterns:

Each log message has a LogLevel, defined by an enum:

export enum LogLevel {
  DEBUG = 0,
  INFO = 1,
  ERROR = 2,
}

For the sake of simplicity, we restrict our Logger library to just three log levels.

An abstract LoggerConfig defines the possible configuration options:

export abstract class LoggerConfig {
  abstract level: LogLevel;
  abstract formatter: Type<LogFormatter>;
  abstract appenders: Type<LogAppender>[];
}

It's an abstract class on purpose, as interfaces cannot be used as tokens for DI. A constant of this class type defines the default values for the configuration options:

export const defaultConfig: LoggerConfig = {
  level: LogLevel.DEBUG,
  formatter: DefaultLogFormatter,
  appenders: [DefaultLogAppender],
};

The LogFormatter is used for formatting log messages before they are published via a LogAppender:

export abstract class LogFormatter {
  abstract format(level: LogLevel, category: string, msg: string): string;
}

Like the LoggerConfiguration, the LogFormatter is an abstract class used as a token. The consumer of the logger lib can adjust the formatting by providing its own implementation. As an alternative, they can go with a default implementation provided by the lib:

@Injectable()
export class DefaultLogFormatter implements LogFormatter {
  format(level: LogLevel, category: string, msg: string): string {
    const levelString = LogLevel[level].padEnd(5);
    return [${levelString}] ${category.toUpperCase()} ${msg};
  }
}

The LogAppender is another exchangeable concept responsible for appending the log message to a log:

export abstract class LogAppender {
  abstract append(level: LogLevel, category: string, msg: string): void;
}

The default implementation writes the message to the console:

@Injectable()
export class DefaultLogAppender implements LogAppender {
  append(level: LogLevel, category: string, msg: string): void {
    console.log(category + ' ' + msg);
  }
}

While there can only be one LogFormatter, the library supports several LogAppenders. For instance, a first LogAppender could write the message to the console while a second one could also send it to the server.

To make this possible, the individual LogAppenders are registered via multi providers. Hence, the Injector returns all of them within an array. As an array cannot be used as a DI token, the example uses an InjectionToken instead:

export const LOG_APPENDERS = new InjectionToken<LogAppender[]>("LOG_APPENDERS");

The LoggserService itself receives the LoggerConfig, the LogFormatter, and an array with LogAppenders via DI and allows to log messages for several LogLevels:

@Injectable()
export class LoggerService {
  private config = inject(LoggerConfig);
  private formatter = inject(LogFormatter);
  private appenders = inject(LOG_APPENDERS);

  log(level: LogLevel, category: string, msg: string): void {
    if (level < this.config.level) {
      return;
    }
    const formatted = this.formatter.format(level, category, msg);
    for (const a of this.appenders) {
      a.append(level, category, formatted);
    }
  }

  error(category: string, msg: string): void {
    this.log(LogLevel.ERROR, category, msg);
  }

  info(category: string, msg: string): void {
    this.log(LogLevel.INFO, category, msg);
  }

  debug(category: string, msg: string): void {
    this.log(LogLevel.DEBUG, category, msg);
  }
}

The Golden Rule

Before I start with presenting the inferred patterns, I want to stress out what I call the golden rule for providing services:

Whenever possible, use @Injectable({providedIn: 'root'})!

Especially in application code but in several situations in libraries, this is what you want to have: It's easy, tree-shakable, and even works with lazy loading. The latter aspect is less a merit of Angular than the underlying bundler: Everything that is just needed in a lazy bundle is put there.

Pattern: Provider Factory

Intentions

  • Providing services for a reusable lib
  • Configuring a reusable lib
  • Exchanging defined implementation details

Description

A Provider Factory is a function returning an array with providers for a given library. This Array is cross-casted into Angular's EnvironmentProviders type to make sure the providers can only be used in an environment scope -- first and foremost, the root scope and scopes introduced with lazy routing configurations.

Angular and NGRX place such functions in files called provider.ts.

Example

The following Provider Function provideLogger takes a partial LoggerConfiguration and uses it to create some providers:

export function provideLogger(
  config: Partial<LoggerConfig>
): EnvironmentProviders {
  // using default values for missing properties
  const merged = { ...defaultConfig, ...config };

  return makeEnvironmentProviders([
    {
      provide: LoggerConfig,
      useValue: merged,
    },
    {
      provide: LogFormatter,
      useClass: merged.formatter,
    },
    merged.appenders.map((a) => ({
      provide: LOG_APPENDERS,
      useClass: a,
      multi: true,
    })),
  ]);
}

Missing configuration values are taken from the default configuration. Angular's makeEnvironmentProviders wraps the Provider array in an instance of EnvironmentProviders.

This function allows the consuming application to setup the logger during bootstrapping like other libraries, e. g. the HttpClient or the Router:

bootstrapApplication(AppComponent, {
  providers: [

    provideHttpClient(),

    provideRouter(APP_ROUTES),

    [...]

    // Setting up the Logger:
    provideLogger(loggerConfig),
  ]
}

Occurrences and Variations

  • This is a usual pattern used in all examined libraries
  • The Provider Factories for the Router and HttpClient have a second optional parameter that takes additional features (see Pattern Feature, below).
  • Instead of passing in the concrete service implementation, e. g. LogFormatter, NGRX allows taking either a token or the concrete object for reducers.
  • The HttpClient takes an array with functional interceptors via a with function (see Pattern Feature, below). These functions are also registered as services.

Pattern: Feature

Intentions

  • Activating and configuring optional features
  • Making these features tree-shakable
  • Providing the underlying services via the current environment scope

Description

The Provider Factory takes an optional array with a feature object. Each feature object has an identifier called kind and a providers array. The kind property allows for validating the combination of passed features. For instance, there might be mutually exclusive features like configuring XSRF token handling and disabling XSRF token handling for the HttpClient.

Example

Our example uses a color feature that allows for displaying messages of different LoggerLevels in different colors:

For categorizing features, an enum is used:

export enum LoggerFeatureKind {
    COLOR,
    OTHER_FEATURE,
    ADDITIONAL_FEATURE
}

Each feature is represented by an object of LoggerFeature:

export interface LoggerFeature {
  kind: LoggerFeatureKind;
  providers: Provider[];
}

For providing the color feature, a factory function following the naming pattern withFeature is introduced:

export function withColor(config?: Partial<ColorConfig>): LoggerFeature {
  const internal = { ...defaultColorConfig, ...config };

  return {
    kind: LoggerFeatureKind.COLOR,
    providers: [
      {
        provide: ColorConfig,
        useValue: internal,
      },
      {
        provide: ColorService,
        useClass: DefaultColorService,
      },
    ],
  };
}

The Provider Factory takes several features via an optional second parameter defined as a rest array:

export function provideLogger(
  config: Partial<LoggerConfig>,
  ...features: LoggerFeature[]
): EnvironmentProviders {
  const merged = { ...defaultConfig, ...config };

  // Inspecting passed features
  const colorFeatures =
    features?.filter((f) => f.kind === LoggerFeatureKind.COLOR)?.length ?? 0;

  // Validating passed features
  if (colorFeatures > 1) {
    throw new Error("Only one color feature allowed for logger!");
  }

  return makeEnvironmentProviders([
    {
      provide: LoggerConfig,
      useValue: merged,
    },
    {
      provide: LogFormatter,
      useClass: merged.formatter,
    },
    merged.appenders.map((a) => ({
      provide: LOG_APPENDERS,
      useClass: a,
      multi: true,
    })),

    // Providing services for the features
    features?.map((f) => f.providers),
  ]);
}

The kind property of the feature is used to examine and validate the passed features. If everything is fine, the providers found in the feature are put into the returned EnvironmentProviders object.

The DefaultLogAppender gets hold of the ColorService provided by the color feature via dependency injection:

export class DefaultLogAppender implements LogAppender {
  colorService = inject(ColorService, { optional: true });

  append(level: LogLevel, category: string, msg: string): void {
    if (this.colorService) {
      msg = this.colorService.apply(level, msg);
    }
    console.log(msg);
  }
}

As features are optional, the DefaultLogAppender passes optional: true to inject. Otherwise, we would get an exception if the feature is not applied. Also, the DefaultLogAppender needs to check for null values.

Occurrences and Variations

  • The Router uses it, e. g. for configuring preloading or for activating debug tracing.
  • The HttpClient uses it, e. g. for providing interceptors, configuring JSONP, and configuring/ disabling the XSRF token handling
  • Both, the Router and the HttpClient, combine the possible features to a union type (e.g. export type AllowedFeatures = ThisFeature | ThatFeature). This helps IDEs to propose built-in features.
  • Some implementations inject the current Injector and use it to find out which features have been configured. This is an imperative alternative to using optional: true.
  • Angular's feature implementations prefix the properties kind and providers with ɵ and hence declare them as internal properties.

Pattern: Configuration Provider Factory

Intentions

  • Configuring existing services
  • Providing additional services and registering them with existing services
  • Extending the behavior of a service from within a nested environment scope

Description

Configuration Provider Factories extend the behavior of an existing service. They may provide additional services and use an ENVIRONMENT_INITIALIZER to get hold of instances of both the provided services as well as the existing services to extend.

Example

Let's assume an extended version of our LoggerService that allows for defining an additional LogAppender for each log category:

@Injectable()
export class LoggerService {

    private appenders = inject(LOG_APPENDERS);
    private formatter = inject(LogFormatter);
    private config = inject(LoggerConfig);
    [...]

    // Additional LogAppender per log category
    readonly categories: Record<string, LogAppender> = {};

    log(level: LogLevel, category: string, msg: string): void {

        if (level < this.config.level) {
            return;
        }

        const formatted = this.formatter.format(level, category, msg);

        // Lookup appender for this very category and use
        // it, if there is one:
        const catAppender = this.categories[category];

        if (catAppender) {
            catAppender.append(level, category, formatted);
        }

        // Also, use default appenders:
        for (const a of this.appenders) {
            a.append(level, category, formatted);
        }

    }

    [...]
}

To configurate a LogAppender for a category, we can introduce another Provider Factory:

export function provideCategory(
  category: string,
  appender: Type<LogAppender>
): EnvironmentProviders {
  // Internal/ Local token for registering the service
  // and retrieving the resolved service instance
  // immediately after.
  const appenderToken = new InjectionToken<LogAppender>("APPENDER_" + category);

  return makeEnvironmentProviders([
    {
      provide: appenderToken,
      useClass: appender,
    },
    {
      provide: ENVIRONMENT_INITIALIZER,
      multi: true,
      useValue: () => {
        const appender = inject(appenderToken);
        const logger = inject(LoggerService);

        logger.categories[category] = appender;
      },
    },
  ]);
}

This factory creates a provider for the LogAppender class. However, we don't need the class but rather an instance of it. Also, we need the Injector to resolve this instance's dependencies. Both happen when retrieving a LogAppender via inject.

Precisely this is done by the ENVIRONMENT_INITIALIZER, which is multi provider bound to the token ENVIRONMENT_INITIALIZER and pointing to a function. It gets the LogAppender injected but also the LoggerService. Then, the LogAppender is registered with the logger.

This allows for extending the existing LoggerService that can even come from a parent scope. For instance, the following example assumes the LoggerService in the root scope while the additional log category is setup in the scope of a lazy route:

export const FLIGHT_BOOKING_ROUTES: Routes = [
  {
    path: '',
    component: FlightBookingComponent,

    // Providers for this route and child routes
    // Using the providers array sets up a new
    // environment injector for this part of the
    // application.
    providers: [
      // Setting up an NGRX feature slice
      provideState(bookingFeature),
      provideEffects([BookingEffects]),

      // Provide LogAppender for logger category
      provideCategory('booking', DefaultLogAppender),
    ],
    children: [
      {
        path: 'flight-search',
        component: FlightSearchComponent,
      },
      [...]
    ],
  },
];

Occurrences and Variations

  • @ngrx/store uses this pattern to register feature slices
  • @ngrx/effects uses this pattern, to wire-up effects provided by a feature
  • The feature withDebugTracing uses this pattern to subscribe to the Router's events Observable.

Pattern: NgModule Bridge

Intentions

  • Not breaking existing code using NgModules when switching to Standalone APIs.
  • Allowing such application parts to set up EnvironmentProviders that come from a Provider Factory.

Remarks: For new code, this pattern seems to be overkill, because the Provider Factory can be directly called for the consuming (legacy) NgModules.

Description

The NgModule Bridge is a NgModule deriving (some of) its providers via a Provider Factory (see pattern Provider Factory). To give the caller more control over the provided services, static methods like forRoot can be used. These methods can take a configuration object.

Example

The following NgModules allows for setting up the Logger in a traditional way:

@NgModule({
  imports: [/* your imports here */],
  exports: [/* your exports here */],
  declarations: [/* your delarations here */],
  providers: [/* providers, you _always_ want to get, here */],
})
export class LoggerModule {
  static forRoot(config = defaultConfig): ModuleWithProviders<LoggerModule> {
    return {
      ngModule: LoggerModule,
      providers: [
        provideLogger(config)
      ],
    };
  }

  static forCategory(
    category: string,
    appender: Type<LogAppender>
  ): ModuleWithProviders<LoggerModule> {
    return {
      ngModule: LoggerModule,
      providers: [
        provideCategory(category, appender)
      ],
    };
  }
}

To avoid reimplementing the Provider Factories, the Module's methods delegate to them. As using such methods is usual when working with NgModules, consumers can leverage existing knowledge and conventions.

Occurrences and Variations

  • All the examined libraries use this pattern to stay backwards compatible

Pattern: Service Chain

Intentions

  • Making a service delegating to another instance of itself in a parent scope.

Description

When the same service is placed in several nested environment injectors, we normally only get the service instance of the current scope. Hence, a call to the service in a nested scope is not respected in the parent scope. To work around this, a service can look up an instance of itself in the parent scope and delegate to it.

Example

Let's assume we provide the logger library again for a lazy route:

export const FLIGHT_BOOKING_ROUTES: Routes = [
  {
    path: '',
    component: FlightBookingComponent,
    canActivate: [() => inject(AuthService).isAuthenticated()],
    providers: [
      // NGRX
      provideState(bookingFeature),
      provideEffects([BookingEffects]),

      // Providing **another** logger for this part of the app:
      provideLogger(
        {
          level: LogLevel.DEBUG,
          chaining: true,
          appenders: [DefaultLogAppender],
        },
        withColor({
          debug: 42,
          error: 43,
          info: 46,
        })
      ),

    ],
    children: [
      {
        path: 'flight-search',
        component: FlightSearchComponent,
      },
      [...]
    ],
  },
];

This sets up another set of the Logger's services in the environment injector of this lazy route and its children. These services are shadowing their counterparts in the root scope. Hence, when a component in the lazy scope calls the LoggerService, the services in the root scope are not triggered.

To prevent this, we can get the LoggerService from the parent scope. More precisely, it's not the parent scope but the "nearest ancestor scope" providing a LoggerService. After that, the service can delegate to its parent. This way, the services are chained:

@Injectable()
export class LoggerService {
  private appenders = inject(LOG_APPENDERS);
  private formatter = inject(LogFormatter);
  private config = inject(LoggerConfig);

  private parentLogger = inject(LoggerService, {
    optional: true,
    skipSelf: true,
  });
  [...]

  log(level: LogLevel, category: string, msg: string): void {

    // 1. Do own stuff here
    [...]

    // 2. Delegate to parent
    if (this.config.chaining && this.parentLogger) {
        this.parentLogger.log(level, category, msg);
    }
  }
  [...]
}

When using inject to get hold of the parent's LoggerService, we need to pass the optional: true to avoid an exception if there is no ancestor scope with a LoggerService. Passing skipSelf: true makes sure, only ancestor scopes are searched. Otherwise, Angular would start with the current scope and retrieve the calling service itself.

Also, the example shown here allows activating/deactivating this behavior via a new chaining flag in the LoggerConfiguration.

Occurrences and Variations

  • The HttpClient uses this pattern to also trigger HttpInterceptors in parent scopes. More details on chaining HttpInterceptors can be found here. Here, the chaining behavior can be activated via a separate feature. Technically, this feature registers another interceptor delegating to services in the parent scope.

Pattern: Functional Service

Intentions

  • Making the usage of libraries more lightweight by using functions as services
  • Reducing indirections by going with ad-hoc functions

Description

Instead of forcing the consumer to implement a class-based service following a given interface, a library also accepts functions. Internally, they can be registered as a service using useValue.

Example

In this example, the consumer can directly pass a function acting as a LogFormatter to provideLogger:

bootstrapApplication(AppComponent, {
  providers: [
    provideLogger(
      {
        level: LogLevel.DEBUG,
        appenders: [DefaultLogAppender],

        // Functional CSV-Formatter
        formatter: (level, cat, msg) => [level, cat, msg].join(";"),
      },
      withColor({
        debug: 3,
      })
    ),
  ],
});

To allow for this, the Logger uses a LogFormatFn type defining the function's signature:

export type LogFormatFn = (
  level: LogLevel,
  category: string,
  msg: string
) => string;

Also, as functions cannot be used as tokens, an InjectionToken is introduced:

export const LOG_FORMATTER = new InjectionToken<LogFormatter | LogFormatFn>(
  "LOG_FORMATTER"
);

This InjectionToken supports both class-based LogFormatter as well as functional ones. This prevents breaking existing code. As a consequence of supporting both, provideLogger needs to treat both cases in a slightly different way:

export function provideLogger(config: Partial<LoggerConfig>, ...features: LoggerFeature[]): EnvironmentProviders {

    const merged = { ...defaultConfig, ...config};

    [...]

    return makeEnvironmentProviders([
        LoggerService,
        {
            provide: LoggerConfig,
            useValue: merged
        },

        // Register LogFormatter
        //  - Functional LogFormatter:  useValue
        //  - Class-based LogFormatters: useClass
        (typeof merged.formatter === 'function' ) ? {
            provide: LOG_FORMATTER,
            useValue: merged.formatter
        } : {
            provide: LOG_FORMATTER,
            useClass: merged.formatter
        },

        merged.appenders.map(a => ({
            provide: LOG_APPENDERS,
            useClass: a,
            multi: true
        })),
        [...]
    ]);
}

While class-based services are registered with useClass, useValue is the right choice for their functional counterparts.

Also, the consumers of the LogFormatter need to be prepared for both the functional as well as class-based approach:

@Injectable()
export class LoggerService {
  private appenders = inject(LOG_APPENDERS);
  private formatter = inject(LOG_FORMATTER);
  private config = inject(LoggerConfig);

  [...]

  private format(level: LogLevel, category: string, msg: string): string {
    if (typeof this.formatter === 'function') {
        return this.formatter(level, category, msg);
    }
    else {
        return this.formatter.format(level, category, msg);
    }
  }

  log(level: LogLevel, category: string, msg: string): void {
    if (level < this.config.level) {
      return;
    }

    const formatted = this.format(level, category, msg);

    [...]
  }
  [...]
}

Occurrences and Variations

  • The HttpClient allows using functional interceptors. They are registered via a feature (see pattern Feature).
  • The Router allows using functions for implementing guards and resolvers.

What's next? More on Architecture!

Please find more information on enterprise-scale Angular architectures in our free eBook (5th edition, 12 chapters):

  • According to which criteria can we subdivide a huge application into sub-domains?
  • How can we make sure, the solution is maintainable for years or even decades?
  • Which options from Micro Frontends are provided by Module Federation?

free ebook

Feel free to download it here now!