Skip to content

Using an Outbox

This is a demonstration of creating a outbox for sending changes to the API "behind the scenes", thus creating non-blocking interactions for the user.

Getting the Sample Code

You can download the sample code for this by finding yourself a nice clean directory on your machine and running

sh
npx gitpick JeffryGonzalez/site-applied-angular/tree/main/projects/angular-reference/state
cd state
npm i && code .

Overview of the Outbox Pattern

Here is a high-level overview of the Outbox Pattern.

Video Walkthrough of the Demo

Code Explanation

In the example shown above, I implemented this using an HttpInterceptor. This allows the baseline functionality without the need for a lot of boilerplate code.

In projects/angular-reference/state/src/shared/state/interceptors.ts

ts
import {
  HttpErrorResponse,
  HttpEventType,
  HttpHandlerFn,
  HttpInterceptorFn,
  HttpRequest,
} from '@angular/common/http';
import { inject } from '@angular/core';

import { catchError, tap } from 'rxjs';
import { OutboxStore } from './outbox-store';
import {
  ErrorResponseEntity,
  OUTBOX_SOURCED,
  OUTBOX_SOURCED_ID,
  RequestEntity,
} from './types';

export function addOutboxFeatureInterceptor(): HttpInterceptorFn {
  return (req: HttpRequest<unknown>, next: HttpHandlerFn) => {
    const outbox = req.context.get(OUTBOX_SOURCED);
    const store = inject(OutboxStore);
    if (outbox) {
      const id = req.context.get(OUTBOX_SOURCED_ID) || crypto.randomUUID();
      const payload: RequestEntity = {
        id,
        timestamp: Date.now(),
        body: outbox.body,
        name: outbox.name,
        kind: outbox.kind,
        method: req.method,
      };
      store.requestSent(payload);
      return next(req).pipe(
        tap((r) => {
          if (r.type === HttpEventType.Response) {
            store.responseReceived({ ...payload, timestamp: Date.now() });
          }
        }),
        catchError((error: HttpErrorResponse) => {
          console.log({
            msg: 'Got an Outbox Error',
            statusText: error.statusText,
            code: error.status,
          });
          const errorPayload: ErrorResponseEntity = {
            ...payload,
            statusText: error.statusText,
            statusCode: error.status,
            message: error.error,

            timestamp: Date.now(),
          };
          store.responseError(errorPayload);
          throw error;
        }),
      );
    } else {
      return next(req);
    }
  };
}

The Angular HttpClient allows you to attach data to a context property and retrieve that data in an interceptor.

That context data looks like this (in projects/angular-reference/state/src/shared/state/types.ts):

ts
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
export const OUTBOX_SOURCED = new HttpContextToken<
  | {
      method: HttpMethod;
      kind: 'deletion' | 'addition' | 'update';
      body: unknown;
      name: string;
    }
  | undefined
>(() => undefined);
export const OUTBOX_SOURCED_ID = new HttpContextToken<string>(() => '');

To append the context metadata, indicating the desire for the outbox pattern, add context to any Http request.

Each feature should have a feature name, to differentiate it in the outbox from any other features using that same data.

There is a helper in projects/angular-reference/state/src/shared/utils.ts that you can use with the HttpClient, as in projects/angular-reference/state/src/outbox/product-api.ts

ts
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';

import { withOutboxHttpContext } from '@outbox'; 
export const FEATURE_NAME = 'products'; 
export type ApiProduct = { id: string; name: string; price: number };

export class ProductsApi {
  #client = inject(HttpClient);
  getProducts() {
    return this.#client.get<ApiProduct[]>('https://some-api/products');
  }

  deleteProduct(id: string) {
    return this.#client.delete<void>(`https://some-api/products/${id}`, {
      context: withOutboxHttpContext(FEATURE_NAME, id, 'DELETE'), 
    });
  }
  addProduct(product: Omit<ApiProduct, 'id'>) {
    return this.#client.post<ApiProduct>('https://some-api/products', product, {
      context: withOutboxHttpContext(FEATURE_NAME, product, 'POST'),  
    });
  }
  updateProduct(product: ApiProduct) {
    return this.#client.put<ApiProduct>(
      `https://some-api/products/${product.id}`,
      product,
      {
        context: withOutboxHttpContext(FEATURE_NAME, product, 'PUT'),  
      },
    );
  }
}

To augment your store, you can use the withOutbox store feature, giving it the the FEATURE_NAME from the API, and a reference to the entities (here I'm giving it a pre-sorted list of entities).

ts
import { computed, inject } from '@angular/core';
import { tapResponse } from '@ngrx/operators';
import {
  patchState,
  signalStore,
  withComputed,
  withFeature,
  withHooks,
  withMethods,
} from '@ngrx/signals';
import {
  removeEntity,
  setEntities,
  setEntity,
  withEntities,
} from '@ngrx/signals/entities';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { map, mergeMap, pipe, switchMap, tap } from 'rxjs';

import { withDevtools } from '@angular-architects/ngrx-toolkit';
import { withOutbox } from '@outbox';
import { ApiProduct, FEATURE_NAME, ProductsApi } from './product-api';
import { sortEntities, withProductSorting } from './product-store-sorting';
import { injectDispatch } from '@ngrx/signals/events';
import { featureErrorEvents } from '../shared/error-handling/actions';
import { HttpErrorResponse } from '@angular/common/http';

export const ProductsStore = signalStore(
  withEntities<ApiProduct>(),
  withDevtools('ProductsOutbox'),
  withProductSorting(),
  withMethods((store) => {
    const service = inject(ProductsApi);
    const errorDispatcher = injectDispatch(featureErrorEvents);
    return {
      load: rxMethod<void>(
        pipe(
          tap(() => patchState(store, { isLoading: true })),
          switchMap(() =>
            service.getProducts().pipe(
              tapResponse(
                (products) =>
                  patchState(store, setEntities(products), {
                    isLoading: false,
                  }),
                (error) => console.error('Error loading products', error),
              ),
            ),
          ),
        ),
      ),
      doublePrice: rxMethod<ApiProduct>(
        pipe(
          map((product) => ({
            ...product,
            price: product.price * 2,
          })),
          mergeMap((product) =>
            service.updateProduct(product).pipe(
              tapResponse(
                (updatedProduct) =>
                  patchState(store, setEntity(updatedProduct)),
                (error: HttpErrorResponse) =>
                  errorDispatcher.createFeatureError({
                    feature: FEATURE_NAME,
                    message: 'Error updating product: ' + error.error,
                  }),
              ),
            ),
          ),
        ),
      ),
      addProduct: rxMethod<Omit<ApiProduct, 'id'>>(
        pipe(
          mergeMap((product) =>
            service.addProduct(product).pipe(
              tapResponse(
                (newProduct) => patchState(store, setEntity(newProduct)),
                (error: HttpErrorResponse) =>
                  errorDispatcher.createFeatureError({
                    feature: FEATURE_NAME,
                    message: 'Error adding product: ' + error.error,
                  }),
              ),
            ),
          ),
        ),
      ),
      deleteProduct: rxMethod<string>(
        pipe(
          mergeMap((id) =>
            service.deleteProduct(id).pipe(
              tapResponse(
                () => patchState(store, removeEntity(id)), // remove the entity from the store
                (error: HttpErrorResponse) =>
                  errorDispatcher.createFeatureError({
                    feature: FEATURE_NAME,
                    message: 'Error deleting product: ' + error.error,
                  }),
              ),
            ),
          ),
        ),
      ),
    };
  }),

  withComputed((store) => {
    return {
      sortedProducts: computed(() => {
        const entities = store.entities();
        const sortKey = store.sortKey();
        const sortOrder = store.sortOrder();
        return sortEntities(entities, sortKey, sortOrder);
      }),
    };
  }),
  withFeature((store) => withOutbox(FEATURE_NAME, store.sortedProducts)), 
  withHooks({
    onInit: (store) => {
      store.load();
    },
  }),
);