Type Less, Discover More: Building Self-Contained Objects in Angular
DEV Community

Type Less, Discover More: Building Self-Contained Objects in Angular

Building enterprise Angular applications often starts with the same pattern: fetching raw data objects from a REST API and storing them directly in application state. However, as applications grow, treating state as passive data containers causes business logic to bleed across components, pipes, and utility files. In this article, we’ll explore how to transform raw data into Smart Objects inside an NgRx SignalStore using Object Enrichment-and how to overcome Angular's Dependency Injection constraints cleanly using runInInjectionContext . πŸ› οΈ Interactive Playground & Code Want to skip straight to the code? You can explore the full, working implementation in the GitHub Repository or test the live reactivity directly below: - ⚑ StackBlitz Workspace: Open Live Playground - GitHub Repository: mehdi985/stackblitz-starters-cat5gpwk 1. The Passive Data Problem When backend APIs return raw JSON payloads, we typically model them using TypeScript interfaces: // πŸ“ user.model.ts export interface UserDto { id: string; firstName: string; lastName: string; roles: string[]; } Because these objects are strictly data containers with zero behavior, domain logic-like formatting full names or validating roles-gets scattered across component helper methods, utility functions, or template pipes. This causes two major issues: - Poor Discoverability: Developers cannot rely on IDE autocomplete ( user. ) to discover available domain rules. They must hunt down utility functions or grep the codebase for pipes. - Logic Duplication: Basic domain rules get rewritten across multiple components, increasing the risk of bugs when business rules change. 2. Step 1: Smart Domain Objects (Without Dependency Injection) To solve this, we can adopt a Smart Object approach via Object Enrichment (a Factory Mixin pattern). Instead of treating User as passive data, we define an enriched interface containing methods, and a factory function that decorates incoming data with self-contained domain behavior. // πŸ“ user.model.ts // 1. Enriched Interface export interface User extends UserDto { fullName(): string; hasRole(role: string): boolean; } // 2. Factory Function (Object Enrichment) export function enrichUser(dto: UserDto): User { return { ...dto, fullName() { return ${this.firstName}${this.lastName}; }, hasRole(role: string) { return this.roles.includes(role); }, }; } The Developer Experience (DX) Win Now, typing user. in TypeScript or an Angular HTML template immediately surfaces fullName() and hasRole() via IDE autocomplete: {{ user.fullName() }} @if (user.hasRole('ADMIN')) { Admin User } Architectural Deep-Dive: Write-Time vs. Read-Time computed() When integrating smart objects into an NgRx SignalStore, an obvious question arises: "Why not keep raw data in store state and enrich it on read using a computed() signal?" // ❌ THE "READ-TIME" ENRICHMENT APPROACH export const UserStore = signalStore( withState({ rawUsers: [] as UserDto[] }), withComputed(({ rawUsers }) => ({ users: computed(() => rawUsers().map(enrichUser)), })) ); While this looks clean, transforming objects inside computed() introduces an architectural leak: - ❌ Read-Time ( computed ): Store holds raw data -> Component A readsusers (Smart Object βœ…) -> Component B readsstore.rawUsers() directly (Passive Data! ❌) - βœ… Write-Time (at Source): API Response -> enrichUser() -> Store holds Smart Objects -> Any read path automatically gets Smart Objects βœ… 1. Architectural Safety: Enriched by Default If state holds raw data, nothing stops a developer from accessing store.rawUsers() directly or deriving a new computed signal straight from base state-completely bypassing the enriched methods. Enriching the payload at write time (right after the API response and immediately before calling patchState ) guarantees that every signal, selector, or component consuming the store automatically receives a smart object. 2. Performance Reality Angular's computed() signal is memoized. It re-evaluates .map() only when rawUsers() updates. Because both write-time mapping and computed() execute exactly once per API payload update, performance is equivalent. Write-time enrichment is purely an architectural choice to enforce data safety. 3. Step 2: Tapping into Singleton Dependencies (Parameter Passing) Real-world domain logic often requires external state. For instance, determining if a user canEdit() might require checking if the user has an 'ADMIN' role and whether the active session has an 'EDIT_USER' permission in a global PermissionsStore : // πŸ“ permissions.store.ts import { Injectable, signal } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class PermissionsStore { private readonly permissions = signal (['EDIT_USER', 'DELETE_USER']); hasPermission(permission: string): boolean { return this.permissions().includes(permission); } togglePermission(permission: string): void { const current = this.permissions(); if (current.includes(permission)) { this.permissions.set(current.filter((p) => p !== permission)); } else { this.permissions.set([...current, permission]); } } } Passing Dependencies as Parameters The simplest way to give our enrichment function access to PermissionsStore is to pass it explicitly: // πŸ“ user.model.ts export function enrichUser(dto: UserDto, permissionsStore: PermissionsStore): User { return { ...dto, fullName() { return ${this.firstName}${this.lastName}; }, canEdit() { return permissionsStore.hasPermission('EDIT_USER') && this.hasRole('ADMIN'); }, }; } Inside our SignalStore, we inject PermissionsStore and thread it into .map() : // πŸ“ user.store.ts export const UserStore = signalStore( { providedIn: 'root' }, withState(initialState), withMethods(( store, userService = inject(UserService), permissionsStore = inject(PermissionsStore) ) => ({ async loadUsers(): Promise { patchState(store, { isLoading: true }); const rawUsers = await firstValueFrom(userService.fetchUsers()); const enrichedUsers = rawUsers.map((dto) => enrichUser(dto, permissionsStore)); patchState(store, { users: enrichedUsers, isLoading: false }); }, })) ); The Drawback: Parameter Drilling As your domain model grows, threading multiple dependencies (AuthService , FeatureFlagStore , Router ) through mapping calls creates brittle function signatures. Adding or removing a dependency breaks every mapping call across your codebase. 4. Step 3: Direct inject() & The NG0200 Trap To eliminate parameter drilling, we can call inject() directly inside the enrichment function: // πŸ“ user.model.ts import { inject } from '@angular/core'; import { PermissionsStore } from './permissions.store'; export interface UserDto { id: string; firstName: string; lastName: string; roles: string[]; } export interface User extends UserDto { fullName(): string; hasRole(role: string): boolean; canEdit(): boolean; } export function enrichUser(dto: UserDto): User { // Resolved via Angular Injection Context at execution time const permissionsStore = inject(PermissionsStore); return { ...dto, fullName() { return ${this.firstName}${this.lastName}; }, hasRole(role: string) { return this.roles.includes(role); }, canEdit() { return permissionsStore.hasPermission('EDIT_USER') && this.hasRole('ADMIN'); }, }; } Now, the signature is pristine: enrichUser(dto) . However, updating your store method to use .map(enrichUser) triggers a runtime crash: // πŸ“ user.store.ts async loadUsers(): Promise { patchState(store, { isLoading: true }); const rawUsers = await firstValueFrom(userService.fetchUsers()); // πŸ’₯ RUNTIME CRASH: NG0200 const enrichedUsers = rawUsers.map(enrichUser); patchState(store, { users: enrichedUsers, isLoading: false }); } NG0200: inject() must be called from an injection context such as a constructor, a factory function, a field initializer, or a function passed to runInInjectionContext. Why Does This Crash? (The Microtask Boundary) Angular’s inject() function relies on an active Injection Context tied strictly to synchronous execution stack frames. [ Synchronous Store Setup ] ──► Injection Context ACTIVE βœ… β”‚ β–Ό await firstValueFrom(...) ──► Microtask Boundary / Async Gap ⏸️ β”‚ β–Ό [ Async Resume Phase ] ──► Injection Context LOST ❌ ──► NG0200 Error! When execution yields at await firstValueFrom(...) , Angular’s synchronous injection stack is cleared. When the API response resolves in a later microtask, calling enrichUser(dto) -> inject(PermissionsStore) finds zero active injection context. 5. Step 3.5: The "Closure Factory" Workaround & Its Vulnerability Developers often attempt to bypass this by creating a factory that captures dependencies synchronously before the await boundary: // πŸ“ user.model.ts export function createEnricher() { const permissionsStore = inject(PermissionsStore); return (dto: UserDto): User => ({ ...dto, fullName() { return ${this.firstName}${this.lastName}; }, canEdit() { return permissionsStore.hasPermission('EDIT_USER') && this.hasRole('ADMIN'); }, }); } // πŸ“ user.store.ts async loadUsers(): Promise { // Captured synchronously before await βœ… const enrich = createEnricher(); const rawUsers = await firstValueFrom(userService.fetchUsers()); const enrichedUsers = rawUsers.map(enrich); patchState(store, { users: enrichedUsers, isLoading: false }); } The Refactoring Trap This workaround relies on an implicit timing rule. If another developer refactors the method months later and inlines const enrich = createEnricher() after the await boundary, the code still compiles cleanly but crashes in production with NG0200 . 6. Step 4: The Final Solution - runInInjectionContext To make context restoration explicit and 100% refactor-proof, we inject Angular's EnvironmentInjector once during store setup and wrap the write-time transformation inside runInInjectionContext : // πŸ“ user.store.ts import { EnvironmentInjector, inject, runInInjectionContext } from '@angular/core'; import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; import { firstValueFrom } from 'rxjs'; import { User, UserDto, enrichUser } from './user

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.