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
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
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):
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
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).
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();
},
}),
);