Skip to main content
The shared layer contains reusable functionality that can be used across multiple features. This includes domain types, UI components, utility services, and signal-based state management stores.
Location: src/app/shared/Everything in the shared layer should be reusable. If something is only used in one feature, it belongs in that feature’s directory instead.

Shared layer structure

src/app/shared/
├── domain/              # Type definitions and domain models
│   ├── notification.type.ts
│   ├── user.type.ts
│   └── userAccessToken.type.ts
├── services/            # Shared services
│   ├── state/           # Signal-based state stores
│   │   ├── auth.store.ts
│   │   └── notifications.store.ts
│   └── utils/           # Utility services
│       ├── local.repository.ts
│       └── platform.service.ts
└── ui/                  # Reusable UI components
    └── notifications.component.ts

Domain types

Domain types define the shape of data used throughout the application. They are TypeScript types or interfaces that represent business entities.

Notification type

src/app/shared/domain/notification.type.ts
/** A notification for the user */
export type Notification = { message: string; type: 'info' | 'error' };

User type

src/app/shared/domain/user.type.ts
/**
 * User type representation on the client side.
 * @description This is a DTO for the user entity without password field
 */
export type User = {
  id: number;
  username: string;
  email: string;
  terms: boolean;
};

/** Null object pattern for the User type */
export const NULL_USER: User = {
  id: 0,
  username: '',
  email: '',
  terms: false,
};
Domain types often include “null object” constants that represent empty or default states. This follows the Null Object Pattern and eliminates the need for null checks.

UserAccessToken type

Combines user data with authentication tokens (from src/app/shared/domain/userAccessToken.type.ts). Import using path alias:
import { User, NULL_USER } from '@domain/user.type';
import { Notification } from '@domain/notification.type';

State management with signals

The application uses Angular signals for reactive state management. Stores are injectable services that manage application state.

AuthStore

Manages authentication state using signals:
src/app/shared/services/state/auth.store.ts
import { Injectable, Signal, WritableSignal, computed, inject, signal } from '@angular/core';
import { User } from '@domain/user.type';
import { NULL_USER_ACCESS_TOKEN, UserAccessToken } from '@domain/userAccessToken.type';
import { LocalRepository } from '@services/utils/local.repository';

/**
 * Signal Store for the Authentication data
 */
@Injectable({
  providedIn: 'root',
})
export class AuthStore {
  #localRepository: LocalRepository = inject(LocalRepository);

  // Private writable signal
  #state: WritableSignal<UserAccessToken> = signal<UserAccessToken>(
    this.#localRepository.load('userAccessToken', NULL_USER_ACCESS_TOKEN),
  );

  // Public computed signals
  userId: Signal<number> = computed(() => this.#state().user.id);
  user: Signal<User> = computed(() => this.#state().user);
  accessToken: Signal<string> = computed(() => this.#state().accessToken);
  isAuthenticated: Signal<boolean> = computed(() => this.accessToken() !== '');
  isAnonymous: Signal<boolean> = computed(() => this.accessToken() === '');

  /**
   * Saves the new state of the Authentication State
   */
  setState(userAccessToken: UserAccessToken): void {
    this.#state.set(userAccessToken);
    this.#localRepository.save('userAccessToken', userAccessToken);
  }
}
Key features:
  • Private writable signal (#state) for encapsulation
  • Public computed signals for derived state
  • Automatic persistence to localStorage
  • Type-safe with TypeScript
  • Reactive - components update automatically
Usage in components:
import { Component, inject } from '@angular/core';
import { AuthStore } from '@state/auth.store';

@Component({
  selector: 'app-user-profile',
  template: `
    <div>
      <h1>Welcome {{ authStore.user().username }}</h1>
      <p>{{ authStore.user().email }}</p>
    </div>
  `
})
export class UserProfileComponent {
  authStore = inject(AuthStore);
}

NotificationsStore

Manages a list of notifications:
src/app/shared/services/state/notifications.store.ts
import { Injectable, Signal, WritableSignal, computed, signal } from '@angular/core';
import { Notification } from '@domain/notification.type';

/**
 * Store for managing notifications
 */
@Injectable({
  providedIn: 'root',
})
export class NotificationsStore {
  #state: WritableSignal<Notification[]> = signal<Notification[]>([]);

  // Public readonly signals
  notifications: Signal<Notification[]> = this.#state.asReadonly();
  count: Signal<number> = computed(() => this.#state().length);

  /**
   * Adds a notification to the list
   */
  addNotification(notification: Notification): void {
    this.#state.update((current) => [...current, notification]);
  }
  
  /**
   * Clears all notifications
   */
  clearNotifications(): void {
    this.#state.set([]);
  }
}
Key features:
  • Immutable updates using update() method
  • Computed signal for derived values (count)
  • Readonly signal exposure for safety
1

WritableSignal

Private signal that holds the actual state:
#state: WritableSignal<T> = signal<T>(initialValue);
2

Computed signals

Derived values that automatically update:
count: Signal<number> = computed(() => this.#state().length);
3

Readonly signals

Public read-only access to state:
items: Signal<T[]> = this.#state.asReadonly();
4

Update methods

Immutable state updates:
this.#state.update((current) => [...current, newItem]);
Import using path alias:
import { AuthStore } from '@state/auth.store';
import { NotificationsStore } from '@state/notifications.store';

Utility services

LocalRepository

Safe wrapper for localStorage with SSR support:
src/app/shared/services/utils/local.repository.ts
import { Injectable, inject } from '@angular/core';
import { PlatformService } from './platform.service';

/**
 * Utility service to access the local storage.
 * - Avoid accessing localStorage when running on the server.
 */
@Injectable({
  providedIn: 'root',
})
export class LocalRepository {
  #platformService = inject(PlatformService);

  /**
   * Saves a value in the local storage
   */
  save<T>(key: string, value: T): void {
    if (this.#platformService.isServer) return;
    const serialized = JSON.stringify(value);
    localStorage.setItem(key, serialized);
  }

  /**
   * Loads a generic value from the local storage
   */
  load<T>(key: string, defaultValue: T): T {
    if (this.#platformService.isServer) return defaultValue;
    const found = localStorage.getItem(key);
    if (found) {
      return JSON.parse(found);
    }
    this.save(key, defaultValue);
    return defaultValue;
  }

  /**
   * Removes a value from the local storage
   */
  remove(key: string): void {
    if (this.#platformService.isServer) return;
    localStorage.removeItem(key);
  }
}
Key features:
  • Type-safe generic methods
  • SSR-compatible (checks platform before accessing localStorage)
  • Automatic JSON serialization/deserialization
  • Default value handling

PlatformService

Detects whether code is running in browser or server (referenced in LocalRepository). Import using path alias:
import { LocalRepository } from '@services/utils/local.repository';
import { PlatformService } from '@services/utils/platform.service';

UI components

NotificationsComponent

Reusable component for displaying notifications:
src/app/shared/ui/notifications.component.ts
import { ChangeDetectionStrategy, Component, InputSignal, OutputEmitterRef, input, output } from '@angular/core';
import { Notification } from '@domain/notification.type';

/**
 * Component to show notifications to the user
 */
@Component({
  selector: 'lab-notifications',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [],
  template: `
    <dialog open>
      <article>
        <header>
          <h2>Notifications</h2>
        </header>
        @for (notification of notifications(); track notification) {
          @if (notification.type === 'error') {
            <input disabled aria-invalid="true" [value]="notification.message" />
          } @else {
            <input disabled aria-invalid="false" [value]="notification.message" />
          }
        }
        <footer>
          <button (click)="close.emit()">Close</button>
        </footer>
      </article>
    </dialog>
  `,
})
export class NotificationsComponent {
  notifications: InputSignal<Notification[]> = input<Notification[]>([]);
  close: OutputEmitterRef<void> = output();
}
Key features:
  • Standalone component
  • Signal-based inputs with input()
  • Type-safe outputs with output()
  • OnPush change detection for performance
  • Modern Angular control flow (@for, @if)
Usage:
import { Component, inject } from '@angular/core';
import { NotificationsComponent } from '@ui/notifications.component';
import { NotificationsStore } from '@state/notifications.store';

@Component({
  selector: 'app-root',
  imports: [NotificationsComponent],
  template: `
    @if (notificationsStore.count() > 0) {
      <lab-notifications 
        [notifications]="notificationsStore.notifications()"
        (close)="notificationsStore.clearNotifications()" />
    }
  `
})
export class AppComponent {
  notificationsStore = inject(NotificationsStore);
}
All UI components in the shared layer should be standalone and use OnPush change detection for optimal performance.
Import using path alias:
import { NotificationsComponent } from '@ui/notifications.component';

Adding new shared functionality

1

Determine the category

Choose the appropriate subdirectory:
  • domain/ - Type definitions
  • services/state/ - State management stores
  • services/utils/ - Utility services
  • services/api/ - API communication
  • ui/ - Reusable components
2

Create the file

Follow naming conventions:
# Domain type
touch src/app/shared/domain/product.type.ts

# Store
ng generate service shared/services/state/cart.store

# UI component
ng generate component shared/ui/modal --standalone
3

Use path aliases

Configure imports in tsconfig.json if needed:
"@domain/*": ["src/app/shared/domain/*"],
"@state/*": ["src/app/shared/services/state/*"],
"@ui/*": ["src/app/shared/ui/*"]
4

Ensure reusability

Make sure the functionality is:
  • Generic enough to be used in multiple features
  • Self-contained with minimal dependencies
  • Well-documented with JSDoc comments

Signal store patterns

When creating new stores, follow these patterns:

Basic store structure

import { Injectable, Signal, WritableSignal, computed, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class MyStore {
  // 1. Private state
  #state: WritableSignal<MyState> = signal<MyState>(initialState);

  // 2. Public computed selectors
  data: Signal<MyState> = this.#state.asReadonly();
  someValue: Signal<string> = computed(() => this.#state().someProperty);

  // 3. Public methods to update state
  updateState(newValue: Partial<MyState>): void {
    this.#state.update((current) => ({ ...current, ...newValue }));
  }
}

State persistence

import { inject } from '@angular/core';
import { LocalRepository } from '@services/utils/local.repository';

export class PersistentStore {
  #localRepository = inject(LocalRepository);
  
  #state = signal(
    this.#localRepository.load('storeKey', defaultValue)
  );

  setState(value: T): void {
    this.#state.set(value);
    this.#localRepository.save('storeKey', value);
  }
}

Collection management

export class ItemsStore {
  #state: WritableSignal<Item[]> = signal<Item[]>([]);

  items: Signal<Item[]> = this.#state.asReadonly();
  count: Signal<number> = computed(() => this.#state().length);

  addItem(item: Item): void {
    this.#state.update((items) => [...items, item]);
  }

  removeItem(id: number): void {
    this.#state.update((items) => items.filter(item => item.id !== id));
  }

  updateItem(id: number, updates: Partial<Item>): void {
    this.#state.update((items) =>
      items.map(item => item.id === id ? { ...item, ...updates } : item)
    );
  }
}
Always use immutable update patterns. Never mutate the signal’s value directly:
// ❌ Wrong - mutates state
this.#state().push(newItem);

// ✅ Correct - creates new array
this.#state.update((current) => [...current, newItem]);

Best practices

Immutable updates

Always create new objects/arrays when updating signals. Never mutate existing values.

Computed signals

Use computed signals for derived state instead of manually syncing values.

Private state

Keep writable signals private. Expose readonly signals or computed values publicly.

Single responsibility

Each store should manage one domain of state (auth, notifications, cart, etc.).
Signals provide automatic dependency tracking and fine-grained reactivity. Components using signals automatically re-render when the signal values change.

Next steps

Architecture overview

Review the complete architecture design

Core layer

Learn about app-wide services and providers

Folder structure

Understand where to place new code

Build docs developers (and LLMs) love