@nicolaselge/ng-store
v0.0.5
Published
Angular Signals-based state management library with CRUD operations and DTO mapping
Maintainers
Readme
🧠 Angular Signal CRUD
Signal Store infrastructure for Angular 16+
A headless, opinionated CRUD infrastructure built on Angular Signals. Designed for long-lived applications, clean architectures, and real-world backend constraints.
✨ Features
- Angular Signals-based stores - Reactive state management with Angular Signals
- Class-based stores - No function-based defineStore, uses familiar class syntax
- Full CRUD abstraction - Complete CRUD operations (single & bulk) out of the box
- Optimistic updates - UI updates immediately
- Soft delete, restore, hard delete - Complete deletion workflow support
- Policy / permission layer - Frontend-side permission checking before operations
- Event bus - Decouple stores and enable cross-store communication
- Type-safe HTTP client - Full TypeScript support with comprehensive options
- Automatic HTTP request logging - Dev mode only, configurable
- DTO Mapping - Automatic transformation between backend DTOs and frontend entities
- Headless & UI-agnostic - Works with any UI framework or library
- Designed for Angular 16+ - Uses stable Angular Signals and functional DI
📦 Installation
npm install @nicolaselge/ng-store🧩 Requirements
Peer dependencies
- Angular >= 16.0.0
- RxJS >= 7.5.0
- TypeScript >= 5.0
This library relies on Angular Signals and functional dependency injection, which are officially stable starting from Angular 16.
🚀 Quick Start
1️⃣ Define your entity
export interface User {
id: number;
name: string;
email: string;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}2️⃣ Create a Store
import { BaseCrudStore } from '@nicolaselge/ng-store';
@Injectable({ providedIn: 'root' })
export class UserStore extends BaseCrudStore<User> {
protected override storeName = 'users';
protected override endpoint = '/api/users';
}3️⃣ Use it in a component
@Component({...})
export class UserListComponent {
readonly users = this.userStore.entities;
readonly loading = this.userStore.loading;
private userStore: UserStore = inject(UserStore);
constructor() {
this.userStore.getAll();
}
delete(user: User) {
this.userStore.deleteOne(user.id);
}
}📚 Complete API Reference
BaseCrudStore<T, TDTO>
The main store class that provides all CRUD operations.
Type Parameters:
T- Entity type (must have anidproperty)TDTO- DTO type (defaults toTif no mapper is used)
Properties:
entities: Signal<T[]>
Reactive Signal containing all entities in the store.
Type: Signal<T[]>
Usage:
readonly users = this.userStore.entities;
// In template
<div *ngFor="let user of users()">{{ user.name }}</div>loading: Signal<boolean>
Reactive Signal indicating if an operation is in progress.
Type: Signal<boolean>
Usage:
readonly loading = this.userStore.loading;
// In template
<div *ngIf="loading()">Loading...</div>selected: Signal<T | null>
Reactive Signal containing the currently selected entity.
Type: Signal<T | null>
Usage:
readonly selectedUser = this.userStore.selected;
// In template
<div *ngIf="selectedUser()">
Details: {{ selectedUser()!.name }}
</div>Read Operations
getOne(id: T['id']): Promise<T>
Retrieves a single entity by its ID.
Parameters:
id- ID of the entity to retrieve
Returns: Promise resolved with the retrieved entity
Features:
- Checks permissions via
PolicyEngine - Updates
selectedwith the retrieved entity - Emits event
{storeName}:get:one - Automatic DTO mapper support
Endpoint: GET {endpoint}/:id
Example:
const user = await this.userStore.getOne(123);
console.log(user.name);
// Entity is automatically set in selected
const selected = this.userStore.selected(); // Signal containing the usergetAll(): Promise<T[]>
Retrieves all entities.
Returns: Promise resolved with the array of entities
Features:
- Checks permissions via
PolicyEngine - Completely replaces the
itemscollection - Emits event
{storeName}:get:all - Automatic DTO mapper support
Endpoint: GET {endpoint}
Example:
const users = await this.userStore.getAll();
// Collection is automatically updated
const allUsers = this.userStore.entities(); // Signal containing all usersgetMany(ids: T['id'][]): Promise<T[]>
Retrieves multiple entities by their IDs.
Parameters:
ids- Array of IDs of entities to retrieve
Returns: Promise resolved with the array of retrieved entities
Features:
- Checks permissions via
PolicyEngine - Updates the
itemscollection with retrieved entities (upsert) - Emits event
{storeName}:get:many - Automatic DTO mapper support
Endpoint: POST {endpoint}/many
Body: Array of entity IDs
Example:
const users = await this.userStore.getMany([1, 2, 3]);
// Entities are automatically added/updated in the collectionCreate Operations
createOne(entity: T): Promise<T>
Creates a new entity.
Parameters:
entity- Entity to create
Returns: Promise resolved with the created entity (returned by server)
Features:
- Checks permissions via
PolicyEngine - Optimistic update: Adds entity immediately to store
- Automatic rollback: Restores state on error
- Emits event
{storeName}:create:one - Automatic DTO mapper support
Endpoint: POST {endpoint}
Body: Entity DTO (or entity if no mapper)
Example:
const newUser: User = {
id: 0,
name: 'John Doe',
email: '[email protected]',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null
};
try {
const created = await this.userStore.createOne(newUser);
console.log('User created:', created);
// User is already visible in this.userStore.entities() (optimistic)
} catch (error) {
console.error('Error:', error);
// State has been automatically restored (rollback)
}createMany(entities: T[]): Promise<T[]>
Creates multiple new entities.
Parameters:
entities- Array of entities to create
Returns: Promise resolved with the created entities (returned by server)
Features:
- Checks permissions via
PolicyEngine - Optimistic update: Adds entities immediately to store
- Automatic rollback: Restores state on error
- Emits event
{storeName}:create:many - Automatic DTO mapper support
Endpoint: POST {endpoint}/bulk
Body: Array of entity DTOs (or entities if no mapper)
Example:
const newUsers: User[] = [
{ id: 0, name: 'John', email: '[email protected]', ... },
{ id: 0, name: 'Jane', email: '[email protected]', ... }
];
const created = await this.userStore.createMany(newUsers);Update Operations
updateOne(entity: T): Promise<T>
Updates an existing entity (full replacement).
Parameters:
entity- Complete entity with updated values
Returns: Promise resolved with the updated entity (returned by server)
Features:
- Checks permissions via
PolicyEngine - Optimistic update: Updates entity immediately in store
- Automatic rollback: Restores state on error
- Emits event
{storeName}:update:one - Automatic DTO mapper support
Endpoint: PUT {endpoint}/:id
Body: Complete entity DTO (or entity if no mapper)
Example:
const updatedUser: User = {
...existingUser,
name: 'John Updated',
email: '[email protected]'
};
const result = await this.userStore.updateOne(updatedUser);updateMany(entities: T[]): Promise<T[]>
Updates multiple existing entities (full replacement).
Parameters:
entities- Array of complete entities with updated values
Returns: Promise resolved with the updated entities (returned by server)
Features:
- Checks permissions via
PolicyEngine - Optimistic update: Updates entities immediately in store
- Automatic rollback: Restores state on error
- Emits event
{storeName}:update:many - Automatic DTO mapper support
Endpoint: PUT {endpoint}/bulk
Body: Array of complete entity DTOs
patchOne(entity: T): Promise<T>
Partially updates an existing entity (PATCH operation).
Parameters:
entity- Entity with only the fields to update (other fields can be omitted)
Returns: Promise resolved with the patched entity (returned by server)
Features:
- Checks permissions via
PolicyEngine - Optimistic update: Updates entity immediately in store
- Automatic rollback: Restores state on error
- Emits event
{storeName}:patch:one - Automatic DTO mapper support
Endpoint: PATCH {endpoint}/:id
Body: Partial entity DTO (only fields to update)
Example:
// Update only the name
const partial: User = {
id: 123,
name: 'New Name'
// email and other fields not included
};
const result = await this.userStore.patchOne(partial);patchMany(entities: T[]): Promise<T[]>
Partially updates multiple existing entities (PATCH operation).
Parameters:
entities- Array of entities with only fields to update
Returns: Promise resolved with the patched entities (returned by server)
Features:
- Checks permissions via
PolicyEngine - Optimistic update: Updates entities immediately in store
- Automatic rollback: Restores state on error
- Emits event
{storeName}:patch:many - Automatic DTO mapper support
Endpoint: PATCH {endpoint}/bulk
Body: Array of partial entity DTOs
Delete Operations
deleteOne(id: T['id']): Promise<T>
Soft deletes an entity (marks as deleted but keeps in database).
Parameters:
id- ID of the entity to soft delete
Returns: Promise resolved with the deleted entity (returned by server)
Features:
- Checks permissions via
PolicyEngine - Optimistic update: Marks entity as deleted immediately (sets
deletedAt) - Automatic rollback: Restores state on error
- Emits event
{storeName}:delete:one - Automatic DTO mapper support
Endpoint: PATCH {endpoint}/:id/delete
Example:
// Soft delete a user
await this.userStore.deleteOne(123);
// Entity is marked as deleted (deletedAt is set)
// It can be restored with restoreOne()deleteMany(ids: T['id'][]): Promise<T[]>
Soft deletes multiple entities (marks as deleted but keeps in database).
Parameters:
ids- Array of IDs of entities to soft delete
Returns: Promise resolved with the deleted entities (returned by server)
Features:
- Checks permissions via
PolicyEngine - Optimistic update: Marks entities as deleted immediately (sets
deletedAt) - Automatic rollback: Restores state on error
- Emits event
{storeName}:delete:many - Automatic DTO mapper support
Endpoint: PATCH {endpoint}/delete
Body: Array of entity IDs
restoreOne(id: T['id']): Promise<T>
Restores a soft-deleted entity (removes the deleted flag).
Parameters:
id- ID of the entity to restore
Returns: Promise resolved with the restored entity (returned by server)
Features:
- Checks permissions via
PolicyEngine - Optimistic update: Removes deleted flag immediately (sets
deletedAtto null) - Automatic rollback: Restores state on error
- Emits event
{storeName}:restore:one - Automatic DTO mapper support
Endpoint: PATCH {endpoint}/:id/restore
Example:
// Restore a previously deleted user
await this.userStore.restoreOne(123);
// Entity is now active (deletedAt is null)restoreMany(ids: T['id'][]): Promise<T[]>
Restores multiple soft-deleted entities (removes the deleted flag).
Parameters:
ids- Array of IDs of entities to restore
Returns: Promise resolved with the restored entities (returned by server)
Features:
- Checks permissions via
PolicyEngine - Optimistic update: Removes deleted flag immediately (sets
deletedAtto null) - Automatic rollback: Restores state on error
- Emits event
{storeName}:restore:many - Automatic DTO mapper support
Endpoint: PATCH {endpoint}/restore
Body: Array of entity IDs
destroyOne(id: T['id']): Promise<void>
Permanently deletes an entity from the database (hard delete).
Parameters:
id- ID of the entity to permanently delete
Returns: Promise that resolves when deletion is complete
Features:
- Checks permissions via
PolicyEngine(requires 'destroy' permission) - Optimistic update: Removes entity immediately from store
- Automatic rollback: Restores state on error (except network errors)
- Emits event
{storeName}:destroy:one
Warning: This operation is irreversible. The entity is permanently deleted.
Endpoint: DELETE {endpoint}/:id
Example:
// Permanently delete a user
await this.userStore.destroyOne(123);
// Entity is removed from store and deleted from databasedestroyMany(ids: T['id'][]): Promise<void>
Permanently deletes multiple entities from the database (hard delete).
Parameters:
ids- Array of IDs of entities to permanently delete
Returns: Promise that resolves when deletion is complete
Features:
- Checks permissions via
PolicyEngine(requires 'destroy' permission) - Optimistic update: Removes entities immediately from store
- Automatic rollback: Restores state on error (except network errors)
- Emits event
{storeName}:destroy:many
Warning: This operation is irreversible. The entities are permanently deleted.
Endpoint: DELETE {endpoint}
Body: Array of entity IDs
🔐 Policy / Permission Layer
Overview
The PolicyEngine service provides a centralized way to check permissions before CRUD operations. By default, all operations are allowed.
Custom Implementation
Override the PolicyEngine to implement your own permission logic:
import { Injectable, inject } from '@angular/core';
import { PolicyEngine, CrudAction } from '@nicolaselge/ng-store';
import { AuthService } from './auth.service';
@Injectable({ providedIn: 'root' })
export class CustomPolicyEngine extends PolicyEngine {
private auth = inject(AuthService);
override can(action: CrudAction, entity?: any): boolean {
const user = this.auth.currentUser();
if (!user) return false;
switch (action) {
case 'destroy':
// Only admins can permanently delete
return user.role === 'admin';
case 'update':
// Users can update their own entities, admins can update any
return user.id === entity?.ownerId || user.role === 'admin';
case 'create':
// Only authenticated users can create
return !!user;
default:
return true;
}
}
}Provide the Custom Engine
// In app.config.ts
import { provideHttpClient } from '@angular/common/http';
import { PolicyEngine } from '@nicolaselge/ng-store';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
{ provide: PolicyEngine, useClass: CustomPolicyEngine }
]
};How it works
- Before each CRUD operation,
BaseCrudStorecallspolicy.can(action, entity) - If
false, an error is thrown:Permission denied: cannot {action} entity in store '{storeName}' - The operation is blocked before any optimistic update or HTTP request
📡 Event Bus
Overview
The EventBus service enables decoupled communication between stores and other parts of your application.
How it works
Stores automatically emit events after successful CRUD operations:
- Format:
{storeName}:{action}:{type} - Examples:
users:create:one,users:update:many,users:delete:one
Listening to Events
import { EventBus } from '@nicolaselge/ng-store';
import { inject, OnInit, OnDestroy } from '@angular/core';
@Component({...})
export class UserNotificationsComponent implements OnInit, OnDestroy {
private eventBus = inject(EventBus);
private notificationService = inject(NotificationService);
ngOnInit() {
// Listen to user creation events
this.eventBus.on('users:create:one', (user) => {
this.notificationService.show(`User ${user.name} created`);
});
// Listen to bulk updates
this.eventBus.on('users:update:many', (users) => {
this.notificationService.show(`${users.length} users updated`);
});
// Listen to all user events
this.eventBus.on('users:delete:one', (user) => {
this.analytics.track('user_deleted', { userId: user.id });
});
}
}Available Events
For a store named users, the following events are emitted:
users:get:one- AftergetOne()succeedsusers:get:all- AftergetAll()succeedsusers:get:many- AftergetMany()succeedsusers:create:one- AftercreateOne()succeedsusers:create:many- AftercreateMany()succeedsusers:update:one- AfterupdateOne()succeedsusers:update:many- AfterupdateMany()succeedsusers:patch:one- AfterpatchOne()succeedsusers:patch:many- AfterpatchMany()succeedsusers:delete:one- AfterdeleteOne()succeedsusers:delete:many- AfterdeleteMany()succeedsusers:restore:one- AfterrestoreOne()succeedsusers:restore:many- AfterrestoreMany()succeedsusers:destroy:one- AfterdestroyOne()succeedsusers:destroy:many- AfterdestroyMany()succeeds
Use Cases
- Analytics: Track user actions
- Notifications: Show success/error messages
- Cross-store updates: Update related stores when data changes
- Side effects: Trigger additional operations
- UI updates: Refresh related components
🔁 DTO Mapping (snake_case ↔ camelCase)
Why DTO Mapping?
This library works internally with camelCase entities, following TypeScript and Angular conventions.
If your backend API returns snake_case JSON (common with SQL-based or legacy backends), you must provide a DTO ↔ Entity mapper.
Benefits:
- Keep frontend code idiomatic and clean
- Decouple backend representation from frontend domain
- Avoid leaking API formats into components and stores
- Change backend format without touching components
Entity Mapper Interface
export interface EntityMapper<DTO, Entity> {
fromDto(dto: DTO): Entity;
toDto(entity: Entity): DTO;
}Complete Example
1. Define DTO (Backend Format)
export interface UserDto {
id: number;
name: string;
email: string;
created_at: string; // snake_case
updated_at: string; // snake_case
deleted_at: string | null;
}2. Define Entity (Frontend Format)
export interface User {
id: number;
name: string;
email: string;
createdAt: string; // camelCase
updatedAt: string; // camelCase
deletedAt: string | null;
}3. Create the Mapper
import { Injectable } from '@angular/core';
import { EntityMapper } from '@nicolaselge/ng-store';
@Injectable({ providedIn: 'root' })
export class UserMapper implements EntityMapper<UserDto, User> {
fromDto(dto: UserDto): User {
return {
id: dto.id,
name: dto.name,
email: dto.email,
createdAt: dto.created_at, // snake_case → camelCase
updatedAt: dto.updated_at, // snake_case → camelCase
deletedAt: dto.deleted_at
};
}
toDto(entity: User): UserDto {
return {
id: entity.id,
name: entity.name,
email: entity.email,
created_at: entity.createdAt, // camelCase → snake_case
updated_at: entity.updatedAt, // camelCase → snake_case
deleted_at: entity.deletedAt
};
}
}4. Create the Store with Mapper
import { Injectable, inject } from '@angular/core';
import { BaseCrudStore } from '@nicolaselge/ng-store';
@Injectable({ providedIn: 'root' })
export class UserStore extends BaseCrudStore<User, UserDto> {
protected override storeName = 'users';
protected override endpoint = '/api/users';
protected mapper = inject(UserMapper);
}5. Use in Component
@Component({...})
export class UserComponent {
private userStore = inject(UserStore);
// The store returns User entities (camelCase), not UserDto
readonly users = this.userStore.entities;
async createUser() {
// You work with User entities (camelCase)
const newUser: User = {
id: 0, // Will be set by the server
name: 'John Doe',
email: '[email protected]',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null
};
// The mapper automatically converts User → UserDto before sending
await this.userStore.createOne(newUser);
}
}How it works
The transformation happens automatically in BaseCrudStore:
On requests (POST, PUT, PATCH):
- The entity is converted to DTO using
mapper.toDto()before sending to the backend - Location:
BaseCrudStore.toDto()→mapper.toDto(entity)
- The entity is converted to DTO using
On responses (GET, POST, PUT, PATCH):
- The DTO from the backend is converted to entity using
mapper.fromDto() - This happens in the
onSuccesscallback and in the returned Promise - Location:
BaseCrudStore.fromDto()→mapper.fromDto(dto)
- The DTO from the backend is converted to entity using
In your components:
- You always work with clean camelCase entities
- The DTO transformation is completely transparent
🌐 HTTP Effects
Overview
HttpEffects is a fully typed HTTP client wrapper that extends Angular's HttpClient with additional features:
- Type-safe requests with full TypeScript support
- Automatic request/response logging (dev mode only)
- Loading and error state management via Signals
- Path parameter resolution (e.g.,
:id→ actual value) - Query parameter handling
- Callback support (onSuccess, onError)
Basic Usage
import { HttpEffects } from '@nicolaselge/ng-store';
import { inject } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpEffects);
// Simple GET request
getUser(id: number) {
return this.http.get<User>('/api/users/:id', {
onSuccess: (user) => console.log('User loaded:', user),
onError: (error) => console.error('Failed to load user:', error),
}).run({ path: { id } });
}
// POST with body and query parameters
searchUsers(query: string) {
return this.http.post<User[], SearchParams>('/api/users/search', {
}).run({
body: { query },
query: { limit: 10, offset: 0 }
});
}
// PUT with path parameters
updateUser(user: User) {
return this.http.put<User, User>('/api/users/:id', {
onSuccess: (updated) => console.log('User updated:', updated),
}).run({
path: { id: user.id },
body: user
});
}
}HTTP Methods
All standard HTTP methods are supported:
get<TResult>(url, opts?)- GET requestpost<TResult, TBody>(url, opts?)- POST requestput<TResult, TBody>(url, opts?)- PUT requestpatch<TResult, TBody>(url, opts?)- PATCH requestdelete<TResult, TBody>(url, opts?)- DELETE request
Request Options
interface HttpEffectsOptions<TResult, TBody> {
// Custom options
onSuccess?: (result: TResult, params?: HttpEffectsParams<TBody>) => void;
onError?: (error: any) => void;
// Standard Angular HTTP options
headers?: HttpHeaders | Record<string, string | string[]>;
observe?: 'body' | 'events' | 'response';
responseType?: 'arraybuffer' | 'blob' | 'json' | 'text';
reportProgress?: boolean;
withCredentials?: boolean;
timeout?: number;
// ... and more (all Angular HttpClient options are supported)
}Request Parameters
interface HttpEffectsParams<TBody> {
path?: Record<string, string | number>; // URL path parameters (e.g., :id)
body?: TBody; // Request body
query?: Record<string, any>; // Query parameters
}Return Value
All HTTP methods return an object with:
interface HttpEffectsResult<TResult> {
run: (params?: HttpEffectsParams<any>) => Promise<TResult>;
loading: Signal<boolean>;
error: Signal<any>;
}Example:
const request = this.http.get<User>('/api/users/:id');
// Access loading state
const isLoading = request.loading(); // Signal<boolean>
// Access error state
const error = request.error(); // Signal<any>
// Execute the request
const user = await request.run({ path: { id: 123 } });📊 HTTP Logging
Overview
HTTP requests are automatically logged in development mode using isDevMode(). Logs are automatically disabled in production builds.
Log Format
Request:
[HTTP] GET /api/users/:id { path: { id: 123 } }Success:
[HTTP] GET /api/users/123 → SUCCESS (45ms) { statusCode: 200, result: {...} }Error:
[HTTP] POST /api/users → ERROR [400] (12ms) { error: {...} }Customizing Logging Behavior
You can override the default behavior by providing a custom configuration:
import { STORE_LOGGER_CONFIG } from '@nicolaselge/ng-store';
// In your app.config.ts or module providers
providers: [
{
provide: STORE_LOGGER_CONFIG,
useValue: {
enableHttpLogs: true, // Force enable (even in production)
enableStoreLogs: false // Disable store logs
}
}
]Configuration Options
interface StoreLoggerConfig {
/**
* Force enable/disable HTTP logs.
* If undefined, uses isDevMode() by default.
*/
enableHttpLogs?: boolean;
/**
* Force enable/disable store logs.
* If undefined, uses isDevMode() by default.
*/
enableStoreLogs?: boolean;
}🌐 Expected Backend REST Contract
Required Entity Fields (Logical Model)
Your entities should have these fields (names can vary, but concepts should exist):
{
"id": "uuid | number",
"createdAt": "ISO string",
"updatedAt": "ISO string",
"deletedAt": null
}Endpoint Structure
The library expects the following REST endpoints:
Read:
GET {endpoint}- Get all entitiesGET {endpoint}/:id- Get one entityPOST {endpoint}/many- Get many entities (body: array of IDs)
Create:
POST {endpoint}- Create one entityPOST {endpoint}/bulk- Create many entities
Update:
PUT {endpoint}/:id- Update one entity (full replacement)PUT {endpoint}/bulk- Update many entitiesPATCH {endpoint}/:id- Partially update one entityPATCH {endpoint}/bulk- Partially update many entities
Delete:
PATCH {endpoint}/:id/delete- Soft delete one entityPATCH {endpoint}/delete- Soft delete many entities (body: array of IDs)PATCH {endpoint}/:id/restore- Restore one entityPATCH {endpoint}/restore- Restore many entities (body: array of IDs)DELETE {endpoint}/:id- Hard delete one entityDELETE {endpoint}- Hard delete many entities (body: array of IDs)
📦 Philosophy
This library is designed for:
- Large Angular applications - Scalable architecture for complex projects
- Long-term maintainability - Clean code, clear patterns, comprehensive documentation
- Clean, layered architectures - Separation of concerns, dependency injection
- Real-world backend constraints - Handles snake_case, soft deletes, etc.
💡 Advanced Patterns
Custom Store with Additional Methods
You can extend BaseCrudStore with custom methods:
@Injectable({ providedIn: 'root' })
export class UserStore extends BaseCrudStore<User> {
protected override storeName = 'users';
protected override endpoint = '/api/users';
// Custom method
async searchUsers(query: string) {
return this.http.post<User[]>(
`${this.endpoint}/search`,
).run({ body: { query } });
}
// Custom computed property
get activeUsers() {
return computed(() =>
this.entities().filter(u => !u.deletedAt)
);
}
}Listening to Store Events
@Component({...})
export class UserEffectsComponent implements OnInit {
private eventBus = inject(EventBus);
private analytics = inject(AnalyticsService);
ngOnInit() {
// Track user creation
this.eventBus.on('users:create:one', (user) => {
this.analytics.track('user_created', {
userId: user.id,
userName: user.name
});
});
// Notify on bulk updates
this.eventBus.on('users:update:many', (users) => {
this.notificationService.show(
`${users.length} users updated successfully`
);
});
}
}Custom Permission Logic
@Injectable({ providedIn: 'root' })
export class CustomPolicyEngine extends PolicyEngine {
private auth = inject(AuthService);
private userRoles = inject(UserRolesService);
override can(action: CrudAction, entity?: any): boolean {
const user = this.auth.currentUser();
if (!user) return false;
// Admin can do everything
if (user.role === 'admin') return true;
// Check specific permissions
switch (action) {
case 'destroy':
return false; // Only admins (checked above)
case 'update':
case 'delete':
// Users can modify their own entities
return entity?.ownerId === user.id;
case 'create':
return this.userRoles.hasPermission(user, 'create:users');
default:
return true;
}
}
}🎯 Best Practices
1. Store Naming
Use descriptive, plural names for stores:
// ✅ Good
protected override storeName = 'users';
protected override storeName = 'products';
protected override storeName = 'orders';
// ❌ Bad
protected override storeName = 'user';
protected override storeName = 'data';2. Entity Structure
Always include required fields for proper CRUD operations:
export interface User {
id: number; // Required: unique identifier
createdAt: string; // Required: creation timestamp
updatedAt: string; // Required: update timestamp
deletedAt?: string | null; // Optional: for soft delete
// ... your custom fields
}3. Error Handling
Always handle errors in your components:
async createUser(user: User) {
try {
await this.userStore.createOne(user);
this.notificationService.showSuccess('User created');
} catch (error: any) {
if (error.message?.includes('Permission denied')) {
this.notificationService.showError('You do not have permission');
} else {
this.notificationService.showError('Failed to create user');
}
}
}4. Using Signals in Templates
Always call Signals as functions in templates:
// ✅ Good
<div *ngFor="let user of users()">{{ user.name }}</div>
<div *ngIf="loading()">Loading...</div>
// ❌ Bad
<div *ngFor="let user of users">{{ user.name }}</div>5. DTO Mapping
Use mappers when your backend uses different naming conventions:
// ✅ Use mapper for snake_case backend
export class UserStore extends BaseCrudStore<User, UserDto> {
protected mapper = inject(UserMapper);
}
// ✅ Skip mapper if backend uses camelCase
export class UserStore extends BaseCrudStore<User> {
// No mapper needed
}🐛 Troubleshooting
Events are not being received
Problem: Event listeners are not triggered.
Solution:
- Ensure the event name matches exactly:
{storeName}:{action}:{type} - Check that the store's
storeNameis correctly set - Verify the listener is registered before the event is emitted
Permissions are always denied
Problem: All operations throw "Permission denied" errors.
Solution:
- Check your
PolicyEngineimplementation - Ensure
can()method returnstruefor allowed operations - Verify the custom
PolicyEngineis properly provided in your app config
DTO mapping not working
Problem: Entities are not transformed to/from DTOs.
Solution:
- Verify the mapper is injected:
protected mapper = inject(UserMapper); - Check that the store extends
BaseCrudStore<User, UserDto>(with DTO type) - Ensure mapper methods (
fromDto,toDto) are correctly implemented
Optimistic updates not visible
Problem: UI doesn't update immediately after mutations.
Solution:
- Use Signals in templates:
users()notusers - Check that you're using the
entitiesgetter:this.userStore.entities - Verify the component is using change detection (Signals trigger it automatically)
🗺️ Roadmap
- v1.0: Stable CRUD ✅
- v1.1: IndexedDB adapter
- v1.2: Devtools & inspection tools
- v2.0: Optional GraphQL adapter
📝 License
[Your License Here]
