skills/angular-architect/SKILL.md
--- name: angular-architect description: Angular architecture: standalone components, signals, NgRx/signal store, lazy loading, micro-frontends, testing with Jest/Cypress, and enterprise patterns. --- # Angular Architect Design, build, and scale Angular applications using modern APIs and enterprise patterns. Covers standalone components, signals reactivity, state management with NgRx and signal store, advanced routing, dependency injection, performance optimization, testing strategies, and mon
npx skillsauth add johnefemer/skillfish skills/angular-architectInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Design, build, and scale Angular applications using modern APIs and enterprise patterns. Covers standalone components, signals reactivity, state management with NgRx and signal store, advanced routing, dependency injection, performance optimization, testing strategies, and monorepo architecture with Nx and Module Federation.
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
bootstrapApplication(AppComponent, appConfig).catch(console.error);
// app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
],
};
@Component({
selector: 'app-header',
standalone: true,
imports: [RouterLink, MatButtonModule, UserAvatarComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<nav class="header">
<a routerLink="/">Home</a>
<app-user-avatar [userId]="currentUserId" />
<button mat-raised-button (click)="logout()">Logout</button>
</nav>
`,
})
export class HeaderComponent {
currentUserId = 'u-123';
logout(): void { /* ... */ }
}
ng generate @angular/core:standalone --path src/app
Manual steps: add standalone: true, move NgModule imports/declarations into the component imports array, remove the component from the module, delete empty modules.
ChangeDetectionStrategy.OnPush. Always set it to avoid unnecessary change detection cycles.import { signal, computed, effect, untracked } from '@angular/core';
const count = signal(0);
count.set(5);
count.update((prev) => prev + 1);
const doubled = computed(() => count() * 2);
effect(() => {
console.log(`Count changed to ${count()}`);
const snapshot = untracked(() => someOtherSignal());
});
@Component({
selector: 'app-slider',
standalone: true,
template: `
<input type="range" [min]="min()" [max]="max()"
[value]="value()" (input)="onInput($event)" />
<span>{{ value() }}</span>
`,
})
export class SliderComponent {
min = input.required<number>(); // required input signal
max = input<number>(100); // optional with default
value = model<number>(50); // two-way binding model
changed = output<number>();
onInput(event: Event): void {
const val = +(event.target as HTMLInputElement).value;
this.value.set(val);
this.changed.emit(val);
}
}
Parent usage: <app-slider [min]="0" [(value)]="brightness" />
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
export class ProductDetailComponent {
private route = inject(ActivatedRoute);
private productService = inject(ProductService);
private productId = toSignal(
this.route.paramMap.pipe(map((p) => p.get('id')!)),
{ initialValue: '' }
);
product = toSignal(
toObservable(this.productId).pipe(
switchMap((id) => this.productService.getById(id))
)
);
}
.set() inside computed(). Computed signals are pure derivations; use effect() for side-effects.{ injector } or run inside constructor / field initializer.// product.actions.ts
export const ProductActions = createActionGroup({
source: 'Product',
events: {
'Load Products': emptyProps(),
'Load Products Success': props<{ products: Product[] }>(),
'Load Products Failure': props<{ error: string }>(),
},
});
// product.feature.ts — createFeature bundles reducer + selectors
export const productFeature = createFeature({
name: 'product',
reducer: createReducer(
initialState,
on(ProductActions.loadProducts, (s) => ({ ...s, loading: true })),
on(ProductActions.loadProductsSuccess, (s, { products }) => ({
...s, products, loading: false,
})),
on(ProductActions.loadProductsFailure, (s, { error }) => ({
...s, error, loading: false,
}))
),
});
// Auto-generated selectors: selectProducts, selectLoading, etc.
// product.effects.ts — functional effect
export const loadProducts$ = createEffect(
(actions$ = inject(Actions), svc = inject(ProductService)) =>
actions$.pipe(
ofType(ProductActions.loadProducts),
mergeMap(() =>
svc.getAll().pipe(
map((products) => ProductActions.loadProductsSuccess({ products })),
catchError((e) => of(ProductActions.loadProductsFailure({ error: e.message })))
)
)
),
{ functional: true }
);
export const CartStore = signalStore(
{ providedIn: 'root' },
withState<CartState>({ items: [], loading: false }),
withComputed(({ items }) => ({
totalItems: computed(() => items().reduce((sum, i) => sum + i.qty, 0)),
totalPrice: computed(() => items().reduce((sum, i) => sum + i.qty * i.price, 0)),
})),
withMethods((store, cartApi = inject(CartApiService)) => ({
addItem(item: CartItem): void {
patchState(store, { items: [...store.items(), item] });
},
removeItem(productId: string): void {
patchState(store, { items: store.items().filter((i) => i.productId !== productId) });
},
checkout: rxMethod<void>(
pipe(
tap(() => patchState(store, { loading: true })),
switchMap(() => cartApi.checkout(store.items()).pipe(
tapResponse({
next: () => patchState(store, { items: [], loading: false }),
error: () => patchState(store, { loading: false }),
})
))
)
),
}))
);
| Pattern | Scope | Best For | |---|---|---| | Component signals | Single component | Local UI state (toggles, form fields) | | Signal Store | Feature / shared | Medium complexity, fewer indirections | | NgRx Store | App-wide | Complex flows, devtools, undo/redo, team scale | | Component Store | Feature (legacy) | Existing codebases not yet on signal store |
patchState or return new objects.concatLatestFrom for dependent state reads.export const routes: Routes = [
{
path: '',
loadComponent: () => import('./home/home.component').then((m) => m.HomeComponent),
},
{
path: 'products',
loadChildren: () => import('./products/product.routes').then((m) => m.PRODUCT_ROUTES),
},
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES),
canMatch: [isAdminGuard],
},
{ path: '**', redirectTo: '' },
];
export const isAdminGuard: CanMatchFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.hasRole('admin') || router.createUrlTree(['/login']);
};
export const productResolver: ResolveFn<Product> = (route) => {
return inject(ProductService).getById(route.paramMap.get('id')!);
};
export const DASHBOARD_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./dashboard-shell.component').then((m) => m.DashboardShellComponent),
children: [
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
{ path: 'overview', loadComponent: () => import('./overview/overview.component').then((m) => m.OverviewComponent) },
{ path: 'analytics', loadComponent: () => import('./analytics/analytics.component').then((m) => m.AnalyticsComponent) },
{ path: 'notifications', outlet: 'sidebar', loadComponent: () => import('./notifications/notifications.component').then((m) => m.NotificationsComponent) },
],
},
];
<main><router-outlet /></main>
<aside><router-outlet name="sidebar" /></aside>
loadComponent/loadChildren with dynamic import().*.routes.ts files.// Tree-shakable service
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
login(creds: Credentials): Observable<AuthToken> {
return this.http.post<AuthToken>('/api/v1/auth/login', creds);
}
}
// InjectionToken with factory
export const APP_CONFIG = new InjectionToken<AppConfig>('AppConfig');
export function provideAppConfig(): Provider {
return {
provide: APP_CONFIG,
useFactory: () => ({
apiBaseUrl: environment.apiBaseUrl,
featureFlags: environment.featureFlags,
}),
};
}
export const ANALYTICS_ADAPTER = new InjectionToken<AnalyticsAdapter>('AnalyticsAdapter');
export function provideAnalytics(): Provider[] {
return [
{ provide: ANALYTICS_ADAPTER, useClass: GoogleAnalyticsAdapter, multi: true },
{ provide: ANALYTICS_ADAPTER, useClass: MixpanelAdapter, multi: true },
];
}
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
private adapters = inject<AnalyticsAdapter[]>(ANALYTICS_ADAPTER);
track(event: string, payload: Record<string, unknown>): void {
this.adapters.forEach((a) => a.track(event, payload));
}
}
// Modern: inject() — works in field initializers, constructors, factory functions
export class OrderComponent {
private orderService = inject(OrderService);
private router = inject(Router);
private destroyRef = inject(DestroyRef);
}
providedIn: 'root' for feature-specific stateful services. Provide at route or component level so they are destroyed with the feature.inject() for cleaner field declarations.multi: true with multiple providers on one token. The last registration silently wins.@Component({
selector: 'app-product-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<article class="card">
<h3>{{ product().name }}</h3>
<p>{{ product().price | currency }}</p>
</article>
`,
})
export class ProductCardComponent {
product = input.required<Product>();
}
@for (item of items(); track item.id) {
<app-product-card [product]="item" />
} @empty {
<p>No products found.</p>
}
@defer (on viewport) {
<app-heavy-chart [data]="chartData()" />
} @placeholder {
<div class="skeleton-chart"></div>
} @loading (minimum 300ms) {
<app-spinner />
} @error {
<p>Failed to load chart.</p>
}
@defer (on interaction; prefetch on idle) {
<app-comments [postId]="postId()" />
} @placeholder {
<button>Show Comments</button>
}
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withPreloading(PreloadAllModules)),
],
};
ng build --stats-json
npx source-map-explorer dist/my-app/browser/**/*.js
// app.config.server.ts
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
provideServerRouting(serverRoutes),
],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
// app.routes.server.ts
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'products/:id', renderMode: RenderMode.Server },
{ path: '**', renderMode: RenderMode.Client },
];
Default change detection everywhere. Triggers checks on every event across the entire tree.track in @for blocks. Angular destroys and recreates every DOM node on each change.@defer blocks to reduce initial bundle size.provideClientHydration() to avoid duplicate HTTP requests after hydration.describe('ProductListComponent', () => {
let fixture: ComponentFixture<ProductListComponent>;
let productService: jasmine.SpyObj<ProductService>;
beforeEach(async () => {
const spy = jasmine.createSpyObj('ProductService', ['getAll']);
await TestBed.configureTestingModule({
imports: [ProductListComponent],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: ProductService, useValue: spy },
],
}).compileComponents();
fixture = TestBed.createComponent(ProductListComponent);
productService = TestBed.inject(ProductService) as jasmine.SpyObj<ProductService>;
});
it('should display products when loaded', () => {
productService.getAll.and.returnValue(of([{ id: '1', name: 'Widget', price: 9.99 }]));
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('.product-item');
expect(items.length).toBe(1);
expect(items[0].textContent).toContain('Widget');
});
});
describe('LoginComponent', () => {
let loader: HarnessLoader;
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [LoginComponent] }).compileComponents();
loader = TestbedHarnessEnvironment.loader(TestBed.createComponent(LoginComponent));
});
it('should submit credentials', async () => {
const email = await loader.getHarness(MatInputHarness.with({ selector: '[data-testid="email"]' }));
const password = await loader.getHarness(MatInputHarness.with({ selector: '[data-testid="password"]' }));
const submit = await loader.getHarness(MatButtonHarness.with({ text: 'Login' }));
await email.setValue('[email protected]');
await password.setValue('secret');
await submit.click();
// Assert navigation or service call
});
});
describe('UserProfileComponent', () => {
const createComponent = createComponentFactory({
component: UserProfileComponent,
providers: [
mockProvider(UserService, {
getProfile: () => of({ name: 'Jane', email: '[email protected]' }),
}),
],
});
it('should display the user name', () => {
const spectator = createComponent();
expect(spectator.query('.user-name')).toHaveText('Jane');
});
});
describe('Product Effects', () => {
let actions$: Observable<any>;
let productService: jasmine.SpyObj<ProductService>;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideMockActions(() => actions$),
{ provide: ProductService, useValue: jasmine.createSpyObj('ProductService', ['getAll']) },
],
});
productService = TestBed.inject(ProductService) as jasmine.SpyObj<ProductService>;
});
it('should load products successfully', () => {
const products = [{ id: '1', name: 'Widget', price: 9.99 }];
actions$ = hot('-a', { a: ProductActions.loadProducts() });
productService.getAll.and.returnValue(cold('--b|', { b: products }));
expect(fromEffects.loadProducts$).toBeObservable(
cold('---c', { c: ProductActions.loadProductsSuccess({ products }) })
);
});
});
describe('ProductCardComponent', () => {
it('should render product name and price', () => {
cy.mount(ProductCardComponent, {
componentProperties: { product: { id: '1', name: 'Gadget', price: 29.99 } },
});
cy.get('h3').should('contain.text', 'Gadget');
cy.get('p').should('contain.text', '$29.99');
});
});
fixture.detectChanges() after signal updates. Signal components still need a CD cycle in TestBed.my-org/
apps/
customer-portal/ # Angular app
admin-dashboard/ # Angular app
api/ # NestJS backend
libs/
shared/ui/ # Shared UI components
shared/util/ # Pure utility functions
shared/models/ # TypeScript interfaces
customer/feature-catalog/
customer/data-access/ # NgRx state + API services
admin/feature-users/
npx nx generate @nx/angular:library \
--name=feature-catalog \
--directory=libs/customer/feature-catalog \
--standalone --lazy
// .eslintrc.json
{
"rules": {
"@nx/enforce-module-boundaries": ["error", {
"depConstraints": [
{ "sourceTag": "type:feature", "onlyDependOnLibsWithTags": ["type:data-access", "type:ui", "type:util"] },
{ "sourceTag": "type:data-access", "onlyDependOnLibsWithTags": ["type:util", "type:models"] },
{ "sourceTag": "scope:customer", "onlyDependOnLibsWithTags": ["scope:customer", "scope:shared"] }
]
}]
}
}
// Host — webpack.config.ts
export default withModuleFederationPlugin({
remotes: {
catalog: 'http://localhost:4201/remoteEntry.js',
checkout: 'http://localhost:4202/remoteEntry.js',
},
shared: {
'@angular/core': { singleton: true, strictVersion: true },
'@angular/common': { singleton: true, strictVersion: true },
'@angular/router': { singleton: true, strictVersion: true },
},
});
// Host routes
export const routes: Routes = [
{ path: 'catalog', loadChildren: () => import('catalog/Routes').then((m) => m.CATALOG_ROUTES) },
{ path: 'checkout', loadChildren: () => import('checkout/Routes').then((m) => m.CHECKOUT_ROUTES) },
];
// Remote — webpack.config.ts
export default withModuleFederationPlugin({
name: 'catalog',
exposes: { './Routes': './src/app/catalog.routes.ts' },
shared: {
'@angular/core': { singleton: true, strictVersion: true },
'@angular/router': { singleton: true, strictVersion: true },
},
});
@Injectable({ providedIn: 'root' })
export class ApiService {
private http = inject(HttpClient);
private config = inject(APP_CONFIG);
get<T>(path: string, params?: QueryParams): Observable<T> {
return this.http.get<T>(`${this.config.apiBaseUrl}${path}`, {
params: this.buildParams(params),
});
}
getPage<T>(path: string, params?: QueryParams): Observable<PaginatedResponse<T>> {
return this.http.get<PaginatedResponse<T>>(`${this.config.apiBaseUrl}${path}`, {
params: this.buildParams(params),
});
}
post<T>(path: string, body: unknown): Observable<T> {
return this.http.post<T>(`${this.config.apiBaseUrl}${path}`, body);
}
put<T>(path: string, body: unknown): Observable<T> {
return this.http.put<T>(`${this.config.apiBaseUrl}${path}`, body);
}
delete<T>(path: string): Observable<T> {
return this.http.delete<T>(`${this.config.apiBaseUrl}${path}`);
}
private buildParams(params?: QueryParams): HttpParams {
let hp = new HttpParams();
if (!params) return hp;
if (params.page != null) hp = hp.set('page', params.page);
if (params.pageSize != null) hp = hp.set('pageSize', params.pageSize);
if (params.sort) hp = hp.set('sort', params.sort);
if (params.filter) {
Object.entries(params.filter).forEach(([k, v]) => { hp = hp.set(`filter[${k}]`, v); });
}
return hp;
}
}
shared library. Split into shared/ui, shared/util, shared/models so consumers only import what they need.HttpClient calls scattered across components make endpoint changes expensive and testing harder.content-media
Operations leadership for scaling companies. Process design, OKR execution, operational cadence, and scaling playbooks.
tools
--- name: contract-and-proposal-writer description: **Tier:** POWERFUL **Category:** Business Growth **Domain:** Legal Documents, Business Development, Client Relations --- # Contract & Proposal Writer **Tier:** POWERFUL **Category:** Business Growth **Domain:** Legal Documents, Business Development, Client Relations --- ## Overview Generate professional, jurisdiction-aware business documents: freelance contracts, project proposals, SOWs, NDAs, and MSAs. Outputs structured Markdown with
tools
Loads and manages company context for all C-suite advisor skills. Reads ~/.claude/company-context.md, detects stale context (>90 days), enriches context during conversations
testing
When the user wants to plan a content strategy, decide what content to create, or figure out what topics to cover.