Skip to content

Generate Angular Service

Create a new Angular service named $ARGUMENTS.

Requirements

  1. Read the project's CLAUDE.md for conventions, folder structure, and state management approach.

  2. Determine the state management pattern:

    • If --ngrx is provided or the project uses NgRx Store, generate NgRx artifacts (actions, reducer, selectors, effects).
    • Otherwise, generate a signal-based service.
  3. Determine the target directory:

    Standard Angular CLI project:

    • If the service is app-wide (auth, API, config), place under src/app/core/services/.
    • If the service is feature-specific, place under src/app/features/<feature>/services/.
    • If unclear, ask.

    Nx monorepo project:

    • If the service is a data-access concern (API calls, state), place under libs/ui/data-access/src/lib/.
      • API services go in api/.
      • NgRx state goes in state/<feature>/ (actions, reducers, selectors, effects).
      • HTTP/WebSocket clients go in clients/.
    • If the service is a shared utility (navigation, theme, dialogs), place under libs/ui/shared-ui/src/services/.
    • If unclear, ask.
  4. Generate the appropriate files (see patterns below).


Pattern A: Signal-Based Service

Generate these files:

  • <name>.service.ts - Service class
  • <name>.service.spec.ts - Unit tests
typescript
@Injectable({ providedIn: 'root' })
export class NameService {
  private readonly http = inject(HttpClient);

  // Private writable signals for state
  private readonly _items = signal<Item[]>([]);
  private readonly _loading = signal(false);
  private readonly _error = signal<string | null>(null);

  // Public readonly selectors
  readonly items = this._items.asReadonly();
  readonly loading = this._loading.asReadonly();
  readonly error = this._error.asReadonly();
  readonly itemCount = computed(() => this._items().length);

  loadItems(): void {
    this._loading.set(true);
    this._error.set(null);

    this.http.get<Item[]>('/api/items').pipe(
      tap(items => {
        this._items.set(items);
        this._loading.set(false);
      }),
      catchError(err => {
        this._error.set(err.message);
        this._loading.set(false);
        return EMPTY;
      })
    ).subscribe();
  }
}

Signal-Based Service Test

typescript
describe('NameService', () => {
  let service: NameService;
  let httpTesting: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        provideHttpClient(),
        provideHttpClientTesting()
      ]
    });
    service = TestBed.inject(NameService);
    httpTesting = TestBed.inject(HttpTestingController);
  });

  afterEach(() => httpTesting.verify());

  it('should load items', () => {
    const mockItems = [{ id: '1', name: 'Test' }];

    service.loadItems();
    expect(service.loading()).toBe(true);

    const req = httpTesting.expectOne('/api/items');
    req.flush(mockItems);

    expect(service.items()).toEqual(mockItems);
    expect(service.loading()).toBe(false);
    expect(service.error()).toBeNull();
  });

  it('should handle error', () => {
    service.loadItems();

    const req = httpTesting.expectOne('/api/items');
    req.error(new ProgressEvent('error'), { status: 500, statusText: 'Server Error' });

    expect(service.items()).toEqual([]);
    expect(service.loading()).toBe(false);
    expect(service.error()).toBeTruthy();
  });
});

Pattern B: NgRx Store

Generate these files in state/<feature-name>/:

  • <name>.actions.ts - Action definitions
  • <name>.reducers.ts - Reducer and state interface
  • <name>.selectors.ts - Memoized selectors
  • <name>.effects.ts - Side effects
  • <name>.reducers.spec.ts - Reducer tests
  • <name>.selectors.spec.ts - Selector tests
  • <name>.effects.spec.ts - Effect tests

Also generate in api/:

  • <name>-api.service.ts - HTTP API service (no state, just HTTP calls)
  • <name>-api.service.spec.ts - API service tests

Actions

typescript
import { createActionGroup, emptyProps, props } from '@ngrx/store';

export const ItemsActions = createActionGroup({
  source: 'Items',
  events: {
    'Load Items': emptyProps(),
    'Load Items Success': props<{ items: Item[] }>(),
    'Load Items Failure': props<{ error: string }>(),
    'Add Item': props<{ item: Omit<Item, 'id'> }>(),
    'Add Item Success': props<{ item: Item }>(),
    'Add Item Failure': props<{ error: string }>(),
  }
});

Reducer

typescript
import { createReducer, on } from '@ngrx/store';
import { EntityAdapter, EntityState, createEntityAdapter } from '@ngrx/entity';
import { ItemsActions } from './items.actions';

export interface ItemsState extends EntityState<Item> {
  loading: boolean;
  error: string | null;
}

export const itemsAdapter: EntityAdapter<Item> = createEntityAdapter<Item>();

const initialState: ItemsState = itemsAdapter.getInitialState({
  loading: false,
  error: null
});

export const itemsReducer = createReducer(
  initialState,
  on(ItemsActions.loadItems, state => ({
    ...state,
    loading: true,
    error: null
  })),
  on(ItemsActions.loadItemsSuccess, (state, { items }) =>
    itemsAdapter.setAll(items, { ...state, loading: false })
  ),
  on(ItemsActions.loadItemsFailure, (state, { error }) => ({
    ...state,
    loading: false,
    error
  })),
  on(ItemsActions.addItemSuccess, (state, { item }) =>
    itemsAdapter.addOne(item, state)
  )
);

Selectors

typescript
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ItemsState, itemsAdapter } from './items.reducers';

const selectItemsState = createFeatureSelector<ItemsState>('items');
const { selectAll, selectEntities, selectTotal } = itemsAdapter.getSelectors();

export const ItemsSelectors = {
  selectAllItems: createSelector(selectItemsState, selectAll),
  selectItemEntities: createSelector(selectItemsState, selectEntities),
  selectItemCount: createSelector(selectItemsState, selectTotal),
  selectLoading: createSelector(selectItemsState, state => state.loading),
  selectError: createSelector(selectItemsState, state => state.error),
};

Effects

typescript
import { inject, Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, exhaustMap, map, of } from 'rxjs';
import { ItemsActions } from './items.actions';
import { ItemsApiService } from '../../api/items-api.service';
import { NotificationsActions } from '../notifications/notifications.actions';

@Injectable()
export class ItemsEffects {
  private readonly actions$ = inject(Actions);
  private readonly api = inject(ItemsApiService);

  readonly loadItems$ = createEffect(() =>
    this.actions$.pipe(
      ofType(ItemsActions.loadItems),
      exhaustMap(() =>
        this.api.getItems().pipe(
          map(items => ItemsActions.loadItemsSuccess({ items })),
          catchError(error => of(
            ItemsActions.loadItemsFailure({ error: error.message }),
            NotificationsActions.showError({ message: 'Failed to load items' })
          ))
        )
      )
    )
  );
}

API Service (used by effects)

typescript
@Injectable({ providedIn: 'root' })
export class ItemsApiService {
  private readonly http = inject(HttpClient);

  getItems(): Observable<Item[]> {
    return this.http.get<Item[]>('/api/items');
  }

  createItem(item: Omit<Item, 'id'>): Observable<Item> {
    return this.http.post<Item>('/api/items', item);
  }
}

NgRx Registration

Register the new state slice in the data-access configuration:

typescript
// data-access-state.config.ts
export function provideDataAccessState() {
  return [
    provideState('items', itemsReducer),
    provideEffects(ItemsEffects),
    // ... other state slices
  ];
}

NgRx Tests

Reducer test:

typescript
describe('ItemsReducer', () => {
  it('should set loading on loadItems', () => {
    const state = itemsReducer(initialState, ItemsActions.loadItems());
    expect(state.loading).toBe(true);
    expect(state.error).toBeNull();
  });

  it('should set items on loadItemsSuccess', () => {
    const items = [{ id: '1', name: 'Test' }];
    const state = itemsReducer(initialState, ItemsActions.loadItemsSuccess({ items }));
    expect(selectAll(state)).toEqual(items);
    expect(state.loading).toBe(false);
  });
});

Selector test:

typescript
describe('ItemsSelectors', () => {
  it('should select all items', () => {
    const state = { items: itemsAdapter.setAll([{ id: '1', name: 'Test' }], initialState) };
    expect(ItemsSelectors.selectAllItems.projector(state.items)).toEqual([{ id: '1', name: 'Test' }]);
  });
});

Effect test:

typescript
describe('ItemsEffects', () => {
  let effects: ItemsEffects;
  let actions$: Observable<Action>;
  let api: jest.Mocked<ItemsApiService>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        ItemsEffects,
        provideMockActions(() => actions$),
        { provide: ItemsApiService, useValue: { getItems: jest.fn() } }
      ]
    });
    effects = TestBed.inject(ItemsEffects);
    api = TestBed.inject(ItemsApiService) as jest.Mocked<ItemsApiService>;
  });

  it('should load items on success', () => {
    const items = [{ id: '1', name: 'Test' }];
    api.getItems.mockReturnValue(of(items));
    actions$ = of(ItemsActions.loadItems());

    effects.loadItems$.subscribe(action => {
      expect(action).toEqual(ItemsActions.loadItemsSuccess({ items }));
    });
  });
});

General Conventions

  • Use inject() function (not constructor injection)
  • Use providedIn: 'root' for app-wide services; omit and provide in routes for feature-scoped
  • Use unknown instead of any for ambiguous types
  • Define interfaces for API response types in a models/ or types/ folder
  • Handle loading and error state explicitly

Output

After generating, summarize:

  • Files created and their locations
  • State management pattern used (signal-based or NgRx)
  • Public API (signals/selectors, methods/actions)
  • API endpoints used
  • Any model interfaces created

Copyright © MSG Systems Romania AI Labs