Angular Signals-based state management library with CRUD operations and DTO mapping
> A headless, opinionated CRUD infrastructure built on Angular Signals.
> Designed for long-lived applications, clean architectures, and real-world backend constraints.
---
- 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
---
``bash`
npm install @nicolaselge/ng-store
---
- 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.
---
`ts`
export interface User {
id: number;
name: string;
email: string;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
---
`ts
import { BaseCrudStore } from '@nicolaselge/ng-store';
@Injectable({ providedIn: 'root' })
export class UserStore extends BaseCrudStore
protected override storeName = 'users';
protected override endpoint = '/api/users';
}
`
---
`ts
@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);
}
}
`
---
The main store class that provides all CRUD operations.
Type Parameters:
- T - Entity type (must have an id property)TDTO
- - DTO type (defaults to T if no mapper is used)
Properties:
#### entities: Signal
Reactive Signal containing all entities in the store.
Type: Signal
Usage:
`ts
readonly users = this.userStore.entities;
// In template
---
####
loading: Signal
Reactive Signal indicating if an operation is in progress.Type:
SignalUsage:
`ts
readonly loading = this.userStore.loading;// In template
Loading...
`---
####
selected: Signal
Reactive Signal containing the currently selected entity.Type:
SignalUsage:
`ts
readonly selectedUser = this.userStore.selected;// In template
Details: {{ selectedUser()!.name }}
`---
$3
####
getOne(id: T['id']): PromiseRetrieves a single entity by its ID.
Parameters:
-
id - ID of the entity to retrieveReturns: Promise resolved with the retrieved entity
Features:
- Checks permissions via
PolicyEngine
- Updates selected with the retrieved entity
- Emits event {storeName}:get:one
- Automatic DTO mapper supportEndpoint:
GET {endpoint}/:idExample:
`ts
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 user
`---
####
getAll(): PromiseRetrieves all entities.
Returns: Promise resolved with the array of entities
Features:
- Checks permissions via
PolicyEngine
- Completely replaces the items collection
- Emits event {storeName}:get:all
- Automatic DTO mapper supportEndpoint:
GET {endpoint}Example:
`ts
const users = await this.userStore.getAll();// Collection is automatically updated
const allUsers = this.userStore.entities(); // Signal containing all users
`---
####
getMany(ids: T['id'][]): PromiseRetrieves multiple entities by their IDs.
Parameters:
-
ids - Array of IDs of entities to retrieveReturns: Promise resolved with the array of retrieved entities
Features:
- Checks permissions via
PolicyEngine
- Updates the items collection with retrieved entities (upsert)
- Emits event {storeName}:get:many
- Automatic DTO mapper supportEndpoint:
POST {endpoint}/many
Body: Array of entity IDsExample:
`ts
const users = await this.userStore.getMany([1, 2, 3]);// Entities are automatically added/updated in the collection
`---
$3
####
createOne(entity: T): PromiseCreates a new entity.
Parameters:
-
entity - Entity to createReturns: 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 supportEndpoint:
POST {endpoint}
Body: Entity DTO (or entity if no mapper)Example:
`ts
const newUser: User = {
id: 0,
name: 'John Doe',
email: 'john@example.com',
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[]): PromiseCreates multiple new entities.
Parameters:
-
entities - Array of entities to createReturns: 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 supportEndpoint:
POST {endpoint}/bulk
Body: Array of entity DTOs (or entities if no mapper)Example:
`ts
const newUsers: User[] = [
{ id: 0, name: 'John', email: 'john@example.com', ... },
{ id: 0, name: 'Jane', email: 'jane@example.com', ... }
];const created = await this.userStore.createMany(newUsers);
`---
$3
####
updateOne(entity: T): PromiseUpdates an existing entity (full replacement).
Parameters:
-
entity - Complete entity with updated valuesReturns: 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 supportEndpoint:
PUT {endpoint}/:id
Body: Complete entity DTO (or entity if no mapper)Example:
`ts
const updatedUser: User = {
...existingUser,
name: 'John Updated',
email: 'john.updated@example.com'
};const result = await this.userStore.updateOne(updatedUser);
`---
####
updateMany(entities: T[]): PromiseUpdates multiple existing entities (full replacement).
Parameters:
-
entities - Array of complete entities with updated valuesReturns: 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 supportEndpoint:
PUT {endpoint}/bulk
Body: Array of complete entity DTOs---
####
patchOne(entity: T): PromisePartially 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 supportEndpoint:
PATCH {endpoint}/:id
Body: Partial entity DTO (only fields to update)Example:
`ts
// 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[]): PromisePartially updates multiple existing entities (PATCH operation).
Parameters:
-
entities - Array of entities with only fields to updateReturns: 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 supportEndpoint:
PATCH {endpoint}/bulk
Body: Array of partial entity DTOs---
$3
####
deleteOne(id: T['id']): PromiseSoft deletes an entity (marks as deleted but keeps in database).
Parameters:
-
id - ID of the entity to soft deleteReturns: 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 supportEndpoint:
PATCH {endpoint}/:id/deleteExample:
`ts
// 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'][]): PromiseSoft deletes multiple entities (marks as deleted but keeps in database).
Parameters:
-
ids - Array of IDs of entities to soft deleteReturns: 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 supportEndpoint:
PATCH {endpoint}/delete
Body: Array of entity IDs---
####
restoreOne(id: T['id']): PromiseRestores a soft-deleted entity (removes the deleted flag).
Parameters:
-
id - ID of the entity to restoreReturns: Promise resolved with the restored entity (returned by server)
Features:
- Checks permissions via
PolicyEngine
- Optimistic update: Removes deleted flag immediately (sets deletedAt to null)
- Automatic rollback: Restores state on error
- Emits event {storeName}:restore:one
- Automatic DTO mapper supportEndpoint:
PATCH {endpoint}/:id/restoreExample:
`ts
// Restore a previously deleted user
await this.userStore.restoreOne(123);
// Entity is now active (deletedAt is null)
`---
####
restoreMany(ids: T['id'][]): PromiseRestores multiple soft-deleted entities (removes the deleted flag).
Parameters:
-
ids - Array of IDs of entities to restoreReturns: Promise resolved with the restored entities (returned by server)
Features:
- Checks permissions via
PolicyEngine
- Optimistic update: Removes deleted flag immediately (sets deletedAt to null)
- Automatic rollback: Restores state on error
- Emits event {storeName}:restore:many
- Automatic DTO mapper supportEndpoint:
PATCH {endpoint}/restore
Body: Array of entity IDs---
####
destroyOne(id: T['id']): PromisePermanently deletes an entity from the database (hard delete).
Parameters:
-
id - ID of the entity to permanently deleteReturns: 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:oneWarning: This operation is irreversible. The entity is permanently deleted.
Endpoint:
DELETE {endpoint}/:idExample:
`ts
// Permanently delete a user
await this.userStore.destroyOne(123);
// Entity is removed from store and deleted from database
`---
####
destroyMany(ids: T['id'][]): PromisePermanently deletes multiple entities from the database (hard delete).
Parameters:
-
ids - Array of IDs of entities to permanently deleteReturns: 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:manyWarning: This operation is irreversible. The entities are permanently deleted.
Endpoint:
DELETE {endpoint}
Body: Array of entity IDs---
---
π Policy / Permission Layer
$3
The
PolicyEngine service provides a centralized way to check permissions before CRUD operations. By default, all operations are allowed.$3
Override the
PolicyEngine to implement your own permission logic:`ts
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;
}
}
}
`$3
`ts
// 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 }
]
};
`$3
- Before each CRUD operation,
BaseCrudStore calls policy.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
$3
The
EventBus service enables decoupled communication between stores and other parts of your application.$3
Stores automatically emit events after successful CRUD operations:
- Format:
{storeName}:{action}:{type}
- Examples: users:create:one, users:update:many, users:delete:one$3
`ts
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 });
});
}
}
`$3
For a store named
users, the following events are emitted:-
users:get:one - After getOne() succeeds
- users:get:all - After getAll() succeeds
- users:get:many - After getMany() succeeds
- users:create:one - After createOne() succeeds
- users:create:many - After createMany() succeeds
- users:update:one - After updateOne() succeeds
- users:update:many - After updateMany() succeeds
- users:patch:one - After patchOne() succeeds
- users:patch:many - After patchMany() succeeds
- users:delete:one - After deleteOne() succeeds
- users:delete:many - After deleteMany() succeeds
- users:restore:one - After restoreOne() succeeds
- users:restore:many - After restoreMany() succeeds
- users:destroy:one - After destroyOne() succeeds
- users:destroy:many - After destroyMany() succeeds$3
- 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)
$3
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
---
$3
`ts
export interface EntityMapper {
fromDto(dto: DTO): Entity;
toDto(entity: Entity): DTO;
}
`---
$3
#### 1. Define DTO (Backend Format)
`ts
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)
`ts
export interface User {
id: number;
name: string;
email: string;
createdAt: string; // camelCase
updatedAt: string; // camelCase
deletedAt: string | null;
}
`#### 3. Create the Mapper
`ts
import { Injectable } from '@angular/core';
import { EntityMapper } from '@nicolaselge/ng-store';@Injectable({ providedIn: 'root' })
export class UserMapper implements EntityMapper {
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
`ts
import { Injectable, inject } from '@angular/core';
import { BaseCrudStore } from '@nicolaselge/ng-store';@Injectable({ providedIn: 'root' })
export class UserStore extends BaseCrudStore {
protected override storeName = 'users';
protected override endpoint = '/api/users';
protected mapper = inject(UserMapper);
}
`#### 5. Use in Component
`ts
@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: 'john@example.com',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null
};
// The mapper automatically converts User β UserDto before sending
await this.userStore.createOne(newUser);
}
}
`$3
The transformation happens automatically in
BaseCrudStore:1. 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)2. On responses (GET, POST, PUT, PATCH):
- The DTO from the backend is converted to entity using
mapper.fromDto()
- This happens in the onSuccess callback and in the returned Promise
- Location: BaseCrudStore.fromDto() β mapper.fromDto(dto)3. In your components:
- You always work with clean camelCase entities
- The DTO transformation is completely transparent
---
π HTTP Effects
$3
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)$3
`ts
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('/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('/api/users/search', {
}).run({
body: { query },
query: { limit: 10, offset: 0 }
});
}
// PUT with path parameters
updateUser(user: User) {
return this.http.put('/api/users/:id', {
onSuccess: (updated) => console.log('User updated:', updated),
}).run({
path: { id: user.id },
body: user
});
}
}
`$3
All standard HTTP methods are supported:
-
get - GET request
- post - POST request
- put - PUT request
- patch - PATCH request
- delete - DELETE request$3
`ts
interface HttpEffectsOptions {
// Custom options
onSuccess?: (result: TResult, params?: HttpEffectsParams) => void;
onError?: (error: any) => void; // Standard Angular HTTP options
headers?: HttpHeaders | Record;
observe?: 'body' | 'events' | 'response';
responseType?: 'arraybuffer' | 'blob' | 'json' | 'text';
reportProgress?: boolean;
withCredentials?: boolean;
timeout?: number;
// ... and more (all Angular HttpClient options are supported)
}
`
$3
`ts
interface HttpEffectsParams {
path?: Record; // URL path parameters (e.g., :id)
body?: TBody; // Request body
query?: Record; // Query parameters
}
`$3
All HTTP methods return an object with:
`ts
interface HttpEffectsResult {
run: (params?: HttpEffectsParams) => Promise;
loading: Signal;
error: Signal;
}
`Example:
`ts
const request = this.http.get('/api/users/:id');// Access loading state
const isLoading = request.loading(); // Signal
// Access error state
const error = request.error(); // Signal
// Execute the request
const user = await request.run({ path: { id: 123 } });
`---
π HTTP Logging
$3
HTTP requests are automatically logged in development mode using
isDevMode(). Logs are automatically disabled in production builds.$3
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: {...} }
`$3
You can override the default behavior by providing a custom configuration:
`ts
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
}
}
]
`$3
`ts
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
$3
Your entities should have these fields (names can vary, but concepts should exist):
`json
{
"id": "uuid | number",
"createdAt": "ISO string",
"updatedAt": "ISO string",
"deletedAt": null
}
`$3
The library expects the following REST endpoints:
Read:
-
GET {endpoint} - Get all entities
- GET {endpoint}/:id - Get one entity
- POST {endpoint}/many - Get many entities (body: array of IDs)Create:
-
POST {endpoint} - Create one entity
- POST {endpoint}/bulk - Create many entitiesUpdate:
-
PUT {endpoint}/:id - Update one entity (full replacement)
- PUT {endpoint}/bulk - Update many entities
- PATCH {endpoint}/:id - Partially update one entity
- PATCH {endpoint}/bulk - Partially update many entitiesDelete:
-
PATCH {endpoint}/:id/delete - Soft delete one entity
- PATCH {endpoint}/delete - Soft delete many entities (body: array of IDs)
- PATCH {endpoint}/:id/restore - Restore one entity
- PATCH {endpoint}/restore - Restore many entities (body: array of IDs)
- DELETE {endpoint}/:id - Hard delete one entity
- DELETE {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
$3
You can extend
BaseCrudStore with custom methods:`ts
@Injectable({ providedIn: 'root' })
export class UserStore extends BaseCrudStore {
protected override storeName = 'users';
protected override endpoint = '/api/users'; // Custom method
async searchUsers(query: string) {
return this.http.post(
${this.endpoint}/search,
).run({ body: { query } });
} // Custom computed property
get activeUsers() {
return computed(() =>
this.entities().filter(u => !u.deletedAt)
);
}
}
`$3
`ts
@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
);
});
}
}
`$3
`ts
@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
$3
Use descriptive, plural names for stores:
`ts
// β
Good
protected override storeName = 'users';
protected override storeName = 'products';
protected override storeName = 'orders';// β Bad
protected override storeName = 'user';
protected override storeName = 'data';
`$3
Always include required fields for proper CRUD operations:
`ts
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
Always handle errors in your components:
`ts
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');
}
}
}
`$3
Always call Signals as functions in templates:
`ts
// β
Good
{{ user.name }}
Loading...// β Bad
{{ user.name }}
`$3
Use mappers when your backend uses different naming conventions:
`ts
// β
Use mapper for snake_case backend
export class UserStore extends BaseCrudStore {
protected mapper = inject(UserMapper);
}// β
Skip mapper if backend uses camelCase
export class UserStore extends BaseCrudStore {
// No mapper needed
}
`---
π Troubleshooting
$3
Problem: Event listeners are not triggered.
Solution:
- Ensure the event name matches exactly:
{storeName}:{action}:{type}
- Check that the store's storeName is correctly set
- Verify the listener is registered before the event is emitted$3
Problem: All operations throw "Permission denied" errors.
Solution:
- Check your
PolicyEngine implementation
- Ensure can() method returns true for allowed operations
- Verify the custom PolicyEngine is properly provided in your app config$3
Problem: Entities are not transformed to/from DTOs.
Solution:
- Verify the mapper is injected:
protected mapper = inject(UserMapper);
- Check that the store extends BaseCrudStore (with DTO type)
- Ensure mapper methods (fromDto, toDto) are correctly implemented$3
Problem: UI doesn't update immediately after mutations.
Solution:
- Use Signals in templates:
users() not users
- Check that you're using the entities getter: 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]