Action Registry Promise To Observable Migration
Documentation for Claude Docs
Action Registry: Promise to Observable Migration Workflow
📋 Overview
Objective: Migrate @libs-core/action-registry from Promise-based APIs to RxJS Observable-based APIs to align with project standards ("Always use rxJS, never use promises").
Scope: Complete refactoring of action-registry library including:
- Core services (ActionHandler, ActionRegistry)
- Contract interfaces (ModuleActionContract)
- All action implementations (navigation, ui, form, data)
- Utility services (CircuitBreaker, ActionDiscovery)
- Components (ActionExecutor)
- All tests
Current State Analysis:
- ✅
ExecutableActioninterface ALREADY supports both Promise and Observable - ❌ ActionHandler methods return Promises
- ❌ All action implementations use
async/awaitpattern - ❌ Contract interfaces define Promise-based methods
- ❌ Uses deprecated
.toPromise()method - ❌ Uses
Promise.all()for batch operations
Target State:
- Observable-only APIs throughout the library
- RxJS operators replace async/await
forkJoin()/combineLatest()replacePromise.all()of()/throwError()replacePromise.resolve()/Promise.reject()- Remove all deprecated
.toPromise()calls
🎯 Phase 1: Type System & Contracts (Foundation)
Task 1.1: Update Core Type Definitions
File: libs/core/action-registry/src/lib/models/action-types.ts
Changes:
// BEFORE
export interface ExecutableAction<T = any> {
execute(params: ActionParams, context: ActionContext): Promise<T> | Observable<T> | T;
validate?(params: ActionParams): boolean | Promise<boolean>;
cleanup?(): void;
}
export interface ActionTransactionRequest {
rollback?: (result: ActionResult<any>) => Promise<void>;
}
// AFTER
export interface ExecutableAction<T = any> {
execute(params: ActionParams, context: ActionContext): Observable<T>;
validate?(params: ActionParams): Observable<boolean>;
cleanup?(): void | Observable<void>;
}
export interface ActionTransactionRequest {
rollback?: (result: ActionResult<any>) => Observable<void>;
}
Impact: This is a BREAKING CHANGE. All action implementations must be updated.
Testing: Update type-only tests to ensure compilation succeeds.
Task 1.2: Update Contract Interfaces
File: libs/core/action-registry/src/lib/contracts/module-action-contract.ts
Changes:
// BEFORE
export interface ModuleActionContract {
discoverActions(): Promise<ActionDiscoveryResult>;
getPublicActions(): Promise<ModuleActionConfig[]>;
canExecuteAction(actionId: string, context: ActionContext): Promise<boolean>;
executeAction(actionId: string, params: ActionParams, context: ActionContext): Promise<ActionResult>;
initialize(): Promise<void>;
cleanup(): Promise<void>;
healthCheck(): Promise<ModuleHealthStatus>;
}
// AFTER
export interface ModuleActionContract {
discoverActions(): Observable<ActionDiscoveryResult>;
getPublicActions(): Observable<ModuleActionConfig[]>;
canExecuteAction(actionId: string, context: ActionContext): Observable<boolean>;
executeAction(actionId: string, params: ActionParams, context: ActionContext): Observable<ActionResult>;
initialize(): Observable<void>;
cleanup(): Observable<void>;
healthCheck(): Observable<ModuleHealthStatus>;
}
BaseModuleActionContract: Update all method implementations to return Observable.
Testing: Compile-time checks for interface compliance.
Task 1.3: Update ActionDiscovery Service
File: libs/core/action-registry/src/lib/contracts/action-discovery.ts
Changes: Replace all async methods with Observable-returning methods:
registerModule():Observable<boolean>unregisterModule():Observable<boolean>rediscoverAll():Observable<ActionDiscoveryResult[]>runHealthChecks():Observable<void>
Pattern:
// BEFORE
async registerModule(contract: ModuleActionContract): Promise<boolean> {
const result = await contract.discoverActions();
// ...
return true;
}
// AFTER
registerModule(contract: ModuleActionContract): Observable<boolean> {
return contract.discoverActions().pipe(
switchMap(result => {
// ... process result
return of(true);
}),
catchError(error => {
this.logger().error('Registration failed', { error });
return of(false);
})
);
}
Testing: Integration tests for module discovery flow.
🎯 Phase 2: Core Services (Critical Path)
Task 2.1: ActionHandler Service - Part 1 (Basic Execution)
File: libs/core/action-registry/src/lib/core/action-handler.service.ts
Priority Methods:
execute()- Main execution methodexecuteWithTimeout()- Timeout helperexecuteByType()- Type dispatcher
Key Changes:
2.1.1: Main execute() method
// BEFORE
async execute<T>(actionId: string, parameters: ActionParams = {}, context?: ActionContext): Promise<ActionResult<T>> {
const startTime = Date.now();
const result = await this.circuitBreaker.execute(actionId, () => this.executeByType<T>(...));
return successResult;
}
// AFTER
execute<T>(actionId: string, parameters: ActionParams = {}, context?: ActionContext): Observable<ActionResult<T>> {
const startTime = Date.now();
const execContext = context || new ExecutionContext();
return defer(() => {
const metadata = this.actionRegistry.getActionMetadata(actionId);
if (!metadata) {
return throwError(() => new Error(`Action '${actionId}' not found`));
}
this.actionRegistry.setExecutionState('executing');
this.trackExecution(actionId, 'executing');
return this.circuitBreaker.execute(
actionId,
() => this.executeByType<T>(metadata, parameters, execContext),
{ /* config */ }
).pipe(
map(result => ({
...result,
executionTime: Date.now() - startTime
})),
tap(successResult => {
this.actionRegistry.updateExecutionStats(actionId, true);
this.actionRegistry.setExecutionState('success');
this.untrackExecution(actionId);
this.logger().info(`Action '${actionId}' executed successfully`);
const successEvent = new ActionExecutedEvent({ /* ... */ });
this.eventBus.publish(successEvent).subscribe();
}),
catchError(error => {
this.actionRegistry.updateExecutionStats(actionId, false);
this.actionRegistry.setExecutionState('error');
this.untrackExecution(actionId);
const errorResult = this.createErrorResult<T>(actionId, error, startTime);
const errorEvent = new ActionExecutionFailedEvent({ /* ... */ });
this.eventBus.publish(errorEvent).subscribe();
return of(errorResult);
})
);
});
}
2.1.2: executeWithTimeout() helper
// BEFORE
private async executeWithTimeout<T>(executor: () => Promise<T> | Observable<T> | T, timeoutMs: number): Promise<T> {
const result = executor();
if (result instanceof Promise) return result;
if (result instanceof Observable) return result.toPromise() as Promise<T>; // DEPRECATED!
return Promise.resolve(result);
}
// AFTER
private executeWithTimeout<T>(executor: () => Observable<T> | T, timeoutMs: number): Observable<T> {
const result = executor();
if (result instanceof Observable) {
return timeoutMs > 0
? result.pipe(timeout(timeoutMs))
: result;
}
// Wrap synchronous result in Observable
return of(result as T);
}
Testing: Unit tests for execute(), timeout handling, error handling.
Task 2.2: ActionHandler Service - Part 2 (Batch Operations)
Methods:
executeMany()- Parallel executionexecuteSequence()- Sequential executionexecuteBatch()- Batch with statistics
Key Changes:
2.2.1: executeMany() - Replace Promise.all() with forkJoin()
// BEFORE
async executeMany<T>(requests: Array<{...}>): Promise<ActionResult<T>[]> {
const promises = requests.map(req => this.execute<T>(req.actionId, req.parameters, req.context));
return Promise.all(promises);
}
// AFTER
executeMany<T>(requests: Array<{...}>): Observable<ActionResult<T>[]> {
if (requests.length === 0) {
return of([]);
}
const observables = requests.map(req =>
this.execute<T>(req.actionId, req.parameters, req.context)
);
return forkJoin(observables);
}
2.2.2: executeSequence() - Use concatMap()
// BEFORE
async executeSequence<T>(requests: Array<{...}>): Promise<ActionResult<T>[]> {
const results: ActionResult<T>[] = [];
for (const req of requests) {
const result = await this.execute<T>(req.actionId, req.parameters, req.context);
results.push(result);
if (!result.success) break;
}
return results;
}
// AFTER
executeSequence<T>(requests: Array<{...}>): Observable<ActionResult<T>[]> {
if (requests.length === 0) {
return of([]);
}
return from(requests).pipe(
concatMap(req =>
this.execute<T>(req.actionId, req.parameters, req.context).pipe(
map(result => ({ result, shouldContinue: result.success }))
)
),
takeWhile(({ shouldContinue }) => shouldContinue, true),
map(({ result }) => result),
toArray()
);
}
2.2.3: executeBatch() - Use forkJoin() with statistics
// BEFORE
async executeBatch<T>(requests: ActionBatchRequest[]): Promise<ActionBatchResult<T>> {
const startTime = Date.now();
const promises = requests.map(req => this.execute<T>(req.actionId, req.parameters, req.context));
const results = await Promise.all(promises);
// ... calculate stats
return { total, successful, failed, results, totalExecutionTime };
}
// AFTER
executeBatch<T>(requests: ActionBatchRequest[]): Observable<ActionBatchResult<T>> {
const startTime = Date.now();
if (requests.length === 0) {
return of({
total: 0,
successful: 0,
failed: 0,
results: [],
totalExecutionTime: 0
});
}
const observables = requests.map(req =>
this.execute<T>(req.actionId, req.parameters, req.context)
);
return forkJoin(observables).pipe(
map(results => {
const successful = results.filter(r => r.success).length;
const failed = results.filter(r => !r.success).length;
const totalExecutionTime = Date.now() - startTime;
this.logger().info('Batch execution completed', {
total: requests.length,
successful,
failed,
totalExecutionTime
});
return {
total: requests.length,
successful,
failed,
results,
totalExecutionTime
};
})
);
}
Testing: Unit tests for parallel and sequential execution, batch statistics.
Task 2.3: ActionHandler Service - Part 3 (Transactions)
Methods:
executeTransaction()- Transactional execution with rollbackrollbackTransaction()- Rollback helper
Key Changes:
2.3.1: executeTransaction() - Use concatMap() with rollback logic
// BEFORE
async executeTransaction<T>(requests: ActionTransactionRequest[]): Promise<ActionTransactionResult<T>> {
const results: ActionResult<T>[] = [];
const executedRequests: ActionTransactionRequest[] = [];
try {
for (const request of requests) {
const result = await this.execute<T>(request.actionId, request.parameters, request.context);
results.push(result);
if (!result.success) {
await this.rollbackTransaction(executedRequests.slice().reverse());
return { success: false, /* ... */ rolledBack: true, error: result.error };
}
executedRequests.push(request);
}
return { success: true, /* ... */ rolledBack: false };
} catch (error) {
await this.rollbackTransaction(executedRequests.slice().reverse());
return { success: false, /* ... */ rolledBack: true, error };
}
}
// AFTER
executeTransaction<T>(requests: ActionTransactionRequest[]): Observable<ActionTransactionResult<T>> {
const startTime = Date.now();
const results: ActionResult<T>[] = [];
const executedRequests: ActionTransactionRequest[] = [];
this.logger().info(`Executing transaction with ${requests.length} actions`);
return from(requests).pipe(
concatMap(request => {
this.logger().debug(`Transaction step: ${request.actionId}`);
return this.execute<T>(request.actionId, request.parameters, request.context).pipe(
tap(result => {
results.push(result);
if (result.success) {
executedRequests.push(request);
}
}),
map(result => ({ result, shouldContinue: result.success }))
);
}),
takeWhile(({ shouldContinue }) => shouldContinue, true),
last(),
switchMap(({ result }) => {
if (!result.success) {
this.logger().warn(`Transaction failed at action: ${result.actionId}`, { error: result.error });
return this.rollbackTransaction(executedRequests.slice().reverse()).pipe(
map(() => ({
success: false,
total: requests.length,
executedCount: executedRequests.length,
results,
rolledBack: true,
error: result.error,
totalExecutionTime: Date.now() - startTime
}))
);
}
this.logger().info('Transaction completed successfully', {
total: requests.length,
totalExecutionTime: Date.now() - startTime
});
return of({
success: true,
total: requests.length,
executedCount: executedRequests.length,
results,
rolledBack: false,
totalExecutionTime: Date.now() - startTime
});
}),
catchError(error => {
this.logger().error('Transaction failed with error', { error });
return this.rollbackTransaction(executedRequests.slice().reverse()).pipe(
map(() => ({
success: false,
total: requests.length,
executedCount: executedRequests.length,
results,
rolledBack: true,
error: error as Error,
totalExecutionTime: Date.now() - startTime
}))
);
})
);
}
2.3.2: rollbackTransaction()
// BEFORE
private async rollbackTransaction(requests: ActionTransactionRequest[]): Promise<void> {
for (const request of requests) {
try {
if (request.rollback) {
const originalResult = { /* ... */ };
await request.rollback(originalResult);
}
} catch (error) {
this.logger().error(`Rollback failed for action: ${request.actionId}`, { error });
}
}
}
// AFTER
private rollbackTransaction(requests: ActionTransactionRequest[]): Observable<void> {
this.logger().info(`Rolling back ${requests.length} transaction actions`);
if (requests.length === 0) {
return of(undefined);
}
return from(requests).pipe(
concatMap(request => {
if (!request.rollback) {
return of(undefined);
}
this.logger().debug(`Rolling back action: ${request.actionId}`);
const originalResult: ActionResult = {
success: true,
actionId: request.actionId,
executedAt: new Date()
};
return request.rollback(originalResult).pipe(
tap(() => this.logger().debug(`Rollback completed for: ${request.actionId}`)),
catchError(error => {
this.logger().error(`Rollback failed for action: ${request.actionId}`, { error });
return of(undefined); // Continue with remaining rollbacks
})
);
}),
toArray(),
map(() => {
this.logger().info('Rollback completed');
return undefined;
})
);
}
Testing: Integration tests for transaction success, failure, and rollback scenarios.
Task 2.4: ActionHandler Service - Part 4 (Action Type Executors)
Methods:
executeByType()- DispatcherexecuteComponentAction()- Component actionsexecuteServiceAction()- Service actionsexecuteHttpAction()- HTTP actionsexecuteCustomAction()- Custom actions
Key Changes: All type-specific executors return Observable instead of Promise.
2.4.1: executeByType()
// BEFORE
private async executeByType<T>(metadata: ActionMetadata, parameters: ActionParams, context: ActionContext): Promise<ActionResult<T>> {
switch (metadata.type) {
case 'component': return this.executeComponentAction<T>(metadata, parameters, context);
// ... other cases
}
}
// AFTER
private executeByType<T>(metadata: ActionMetadata, parameters: ActionParams, context: ActionContext): Observable<ActionResult<T>> {
switch (metadata.type) {
case 'component': return this.executeComponentAction<T>(metadata, parameters, context);
case 'service': return this.executeServiceAction<T>(metadata, parameters, context);
case 'http': return this.executeHttpAction<T>(metadata, parameters, context);
case 'custom': return this.executeCustomAction<T>(metadata, parameters, context);
default: return throwError(() => new Error(`Unsupported action type: ${metadata.type}`));
}
}
2.4.2: executeComponentAction()
// BEFORE
private async executeComponentAction<T>(/* ... */): Promise<ActionResult<T>> {
try {
const componentRef = createComponent(metadata.actionClass, { /* ... */ });
const instance = componentRef.instance;
if (instance.validate && !(await instance.validate(parameters))) {
throw new Error('Validation failed');
}
const result = await this.executeWithTimeout(() => instance.execute(parameters, context), metadata.timeout);
if (instance.cleanup) instance.cleanup();
componentRef.destroy();
return { success: true, data: result, executedAt: new Date(), actionId: metadata.id };
} catch (error) {
// cleanup
throw error;
}
}
// AFTER
private executeComponentAction<T>(metadata: ActionMetadata, parameters: ActionParams, context: ActionContext): Observable<ActionResult<T>> {
return defer(() => {
this.logger().info(`Executing component action: ${metadata.id}`);
const componentRef = createComponent(metadata.actionClass, {
environmentInjector: this.injector
});
this.logger().debug(`Component created: ${metadata.name}`);
// Set inputs
if (componentRef.setInput) {
Object.entries(parameters).forEach(([key, value]) => {
componentRef.setInput(key, value);
});
}
const previousRef = this.componentRef();
this.componentRef.set(componentRef);
const instance = componentRef.instance;
if (!this.isExecutableAction(instance)) {
componentRef.destroy();
return throwError(() => new Error(
`Component ${metadata.name} does not implement ExecutableAction`
));
}
// Validation
const validation$ = instance.validate
? instance.validate(parameters)
: of(true);
return validation$.pipe(
switchMap(isValid => {
if (!isValid) {
componentRef.destroy();
return throwError(() => new Error(
`Parameter validation failed for action ${metadata.id}`
));
}
// Execute action
return this.executeWithTimeout(
() => instance.execute(parameters, context),
metadata.timeout
);
}),
map(result => {
this.logger().debug(`Component action executed successfully: ${metadata.id}`);
// Cleanup
if (instance.cleanup) {
const cleanup = instance.cleanup();
if (cleanup instanceof Observable) {
cleanup.subscribe(); // Fire and forget
}
}
componentRef.destroy();
this.componentRef.set(previousRef);
return {
success: true,
data: result,
executedAt: new Date(),
actionId: metadata.id
};
}),
catchError(error => {
const currentRef = this.componentRef();
if (currentRef) {
currentRef.destroy();
this.componentRef.set(null);
}
return throwError(() => new Error(
`Component action execution failed: ${error.message}`
));
})
);
});
}
Similar pattern for: executeServiceAction(), executeCustomAction(), executeHttpAction().
Testing: Unit tests for each action type executor.
Task 2.5: CircuitBreaker Service Update
File: libs/core/action-registry/src/lib/utils/circuit-breaker.service.ts
Changes: Update execute() method to work with Observable-returning functions.
// BEFORE
async execute<T>(operationId: string, operation: () => Promise<T>, config?: CircuitBreakerConfig): Promise<T> {
// ... circuit breaker logic with await
}
// AFTER
execute<T>(operationId: string, operation: () => Observable<T>, config?: CircuitBreakerConfig): Observable<T> {
return defer(() => {
const state = this.getOrCreateState(operationId, config);
if (state.state === 'open') {
if (Date.now() - state.lastFailureTime < state.config.resetTimeout) {
return throwError(() => new Error(`Circuit breaker is OPEN for operation: ${operationId}`));
}
this.logger().info(`Circuit breaker entering HALF_OPEN state: ${operationId}`);
state.state = 'half-open';
state.consecutiveSuccesses = 0;
}
return operation().pipe(
tap(() => this.recordSuccess(operationId)),
catchError(error => {
this.recordFailure(operationId);
return throwError(() => error);
})
);
});
}
Testing: Unit tests for circuit breaker states (closed, open, half-open).
🎯 Phase 3: Action Implementations
Task 3.1: Navigation Actions
File: libs/core/action-registry/src/lib/actions/navigation-actions.ts
Pattern: Replace all async execute(): Promise<T> with execute(): Observable<T>.
Example:
// BEFORE
async execute(params: NavigateParams, context: ActionContext): Promise<void> {
this.logger().info('Navigating to route', { params });
await this.router.navigate([params.path], { queryParams: params.queryParams });
}
// AFTER
execute(params: NavigateParams, context: ActionContext): Observable<void> {
this.logger().info('Navigating to route', { params });
return from(this.router.navigate([params.path], { queryParams: params.queryParams })).pipe(
map(() => undefined)
);
}
Actions to update:
- NavigateAction
- ExternalNavigateAction
- BreadcrumbNavigateAction
- GoBackAction
- GoForwardAction
Testing: Integration tests for navigation flows.
Task 3.2: UI Actions
File: libs/core/action-registry/src/lib/actions/ui-actions.ts
Actions to update:
- ShowNotificationAction
- ChangeThemeAction
- ShowModalAction
- ShowLoaderAction
Example:
// BEFORE
async execute(params: NotificationParams, context: ActionContext): Promise<void> {
this.logger().info('Showing notification', { params });
// ... show notification logic
}
// AFTER
execute(params: NotificationParams, context: ActionContext): Observable<void> {
this.logger().info('Showing notification', { params });
return defer(() => {
// ... show notification logic
return of(undefined);
});
}
Testing: Unit tests for each UI action.
Task 3.3: Form Actions
File: libs/core/action-registry/src/lib/actions/form-actions.ts
Actions to update:
- ValidateFormAction
- SubmitFormAction
- ResetFormAction
- AddDynamicFieldAction
Example:
// BEFORE
async execute(params: SubmitFormParams, context: ActionContext): Promise<any> {
this.logger().info('Submitting form', { params });
const validationResult = await this.validateForm(params);
if (!validationResult.valid) {
throw new Error('Form validation failed');
}
const result = await this.submitFormData(params);
return result;
}
// AFTER
execute(params: SubmitFormParams, context: ActionContext): Observable<any> {
this.logger().info('Submitting form', { params });
return this.validateForm(params).pipe(
switchMap(validationResult => {
if (!validationResult.valid) {
return throwError(() => new Error('Form validation failed'));
}
return this.submitFormData(params);
})
);
}
private validateForm(params: SubmitFormParams): Observable<FormValidationResult> {
// ... validation logic
return of({ valid: true, errors: [] });
}
private submitFormData(params: SubmitFormParams): Observable<any> {
// ... submit logic
return of({ submitted: true });
}
Testing: Integration tests for form submission flow.
Task 3.4: Data Actions
File: libs/core/action-registry/src/lib/actions/data-actions.ts
Actions to update:
- ReadDataAction
- WriteDataAction
- SyncDataAction
Example:
// BEFORE
async execute(params: ReadDataParams, context: ActionContext): Promise<any> {
this.logger().info('Reading data', { params });
const response = await this.httpClient.get(params.endpoint).toPromise();
return response;
}
// AFTER
execute(params: ReadDataParams, context: ActionContext): Observable<any> {
this.logger().info('Reading data', { params });
return this.httpClient.get(params.endpoint).pipe(
tap(response => this.logger().debug('Data read successfully', { response }))
);
}
Testing: Unit tests with HttpTestingController.
🎯 Phase 4: Components & State
Task 4.1: ActionExecutor Component
File: libs/core/action-registry/src/lib/components/action-executor.component.ts
Changes:
// BEFORE
async executeSelectedAction(): Promise<void> {
if (!this.selectedAction()) return;
this.isExecuting.set(true);
this.error.set(null);
try {
const result = await this.actionHandler.execute(
this.selectedAction()!,
this.parameters(),
this.context()
);
this.result.set(result);
this.isExecuting.set(false);
} catch (error) {
this.error.set(error as Error);
this.isExecuting.set(false);
}
}
// AFTER
executeSelectedAction(): void {
const actionId = this.selectedAction();
if (!actionId) return;
this.isExecuting.set(true);
this.error.set(null);
this.actionHandler.execute(
actionId,
this.parameters(),
this.context()
).pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: (result) => {
this.result.set(result);
this.isExecuting.set(false);
},
error: (error) => {
this.error.set(error);
this.isExecuting.set(false);
}
});
}
Testing: Component tests with Observable mocks.
Task 4.2: ActionState Service
File: libs/core/action-registry/src/lib/state/action-state.service.ts
Review: Ensure all state updates work with Observable emissions from ActionHandler.
Testing: State management integration tests.
🎯 Phase 5: Testing Updates
Task 5.1: Update Unit Tests
Files: All *.test.ts files in libs/core/action-registry/tests/
Pattern:
// BEFORE
it('should execute action successfully', async () => {
const result = await actionHandler.execute('test-action', {});
expect(result.success).toBe(true);
});
// AFTER
it('should execute action successfully', (done) => {
actionHandler.execute('test-action', {}).subscribe({
next: (result) => {
expect(result.success).toBe(true);
done();
},
error: done.fail
});
});
// OR using marbles testing
it('should execute action successfully', () => {
const expected$ = cold('--a|', { a: { success: true } });
const result$ = actionHandler.execute('test-action', {});
expect(result$).toBeObservable(expected$);
});
Tests to update:
- action-handler.service.test.ts
- action-registry.service.test.ts
- circuit-breaker.service.test.ts
- All action tests
- Integration tests
Testing: All tests must pass with Observable patterns.
🎯 Phase 6: Dependent Libraries & Validation
Task 6.1: Identify Dependent Libraries
Command:
npx nx graph --focus=action-registry
Known dependents (from library dependency hierarchy):
@gograb-space/user-management- Uses action-registry for user actions
Action: Search for action-registry usage:
npx nx run-many --target=build --all --skip-cache --with-deps
Task 6.2: Update User Management Module
Files to check:
- User management actions
- User management service integration
Changes: Update all action execution calls to subscribe to Observables.
Example:
// BEFORE
async performUserAction(actionId: string, params: ActionParams) {
const result = await this.actionHandler.execute(actionId, params);
return result;
}
// AFTER
performUserAction(actionId: string, params: ActionParams): Observable<ActionResult> {
return this.actionHandler.execute(actionId, params);
}
Task 6.3: Update gograb-admin Application
File: apps/gograb-admin/src/app/features/dashboard/dashboard.component.ts
Review: Dashboard component uses action-registry for bootstrap actions.
Changes: Ensure all action executions use Observable subscriptions.
Task 6.4: Final Validation
Checklist:
- All library tests pass
- All dependent library tests pass
- Application builds successfully
- No compilation errors
- No runtime errors
- All deprecated
.toPromise()calls removed - No
Promise.all()usage remains - All
async/awaitreplaced with RxJS operators - Code follows RxJS best practices
Commands:
# Build action-registry
npx nx build action-registry
# Test action-registry
npx nx test action-registry
# Build all dependent libraries
npx nx run-many --target=build --projects=tag:depends:action-registry
# Test all
npx nx run-many --target=test --all
# Lint
npx nx lint action-registry
# Build application
npx nx build gograb-admin
📊 Migration Statistics
Estimated Files to Modify: 25+ files Estimated Lines of Code: ~3000+ LOC
Breakdown:
- Core services: 2 files (~1000 LOC)
- Contracts: 2 files (~300 LOC)
- Actions: 4 files (~800 LOC)
- Utilities: 3 files (~400 LOC)
- Components: 1 file (~200 LOC)
- Tests: 10+ files (~1500 LOC)
- Dependents: 3+ files (~300 LOC)
🚨 Breaking Changes
Public API Changes:
- All
Promise<T>return types becomeObservable<T> - All
asyncmethods become synchronous Observable-returning methods ExecutableAction.validate()returnsObservable<boolean>instead ofPromise<boolean>ModuleActionContractmethods return ObservablesActionTransactionRequest.rollbackcallback returns Observable
Migration Path for Consumers:
// BEFORE
const result = await actionHandler.execute('my-action', params);
if (result.success) { /* ... */ }
// AFTER
actionHandler.execute('my-action', params).subscribe({
next: (result) => {
if (result.success) { /* ... */ }
},
error: (error) => { /* ... */ }
});
// OR with async pipe in templates
result$ = this.actionHandler.execute('my-action', params);
🔧 Development Guidelines
RxJS Best Practices
- Always unsubscribe: Use
takeUntilDestroyed()ortakeUntil()in components - Error handling: Always provide error handlers in subscribe
- Avoid nested subscriptions: Use
switchMap,mergeMap,concatMap - Cold vs Hot: Most operations are cold (defer execution)
- Multicasting: Use
shareReplay()for expensive operations
Operator Selection
- switchMap: For cancellable operations (e.g., HTTP requests from user input)
- mergeMap: For parallel independent operations
- concatMap: For sequential operations with order preservation
- exhaustMap: For preventing duplicate execution (e.g., button clicks)
- forkJoin: For parallel execution, wait for all to complete (like Promise.all)
- combineLatest: For combining multiple observables, emit when any changes
- concat: For sequential execution of observables
- merge: For parallel execution of observables
✅ Definition of Done
Phase is complete when:
- All files in phase modified and reviewed
- All tests in phase passing
- No compilation errors
- Code follows project standards
- Documentation updated
- Peer review completed
Migration is complete when:
- All 6 phases completed
- Zero
Promiseusage in action-registry - Zero deprecated
.toPromise()calls - All dependent libraries updated and tested
- Application runs without errors
- Performance benchmarks comparable or better
- Migration documented in CHANGELOG
📝 Notes
- Incremental commits: Commit after each completed task
- Feature branch: Work in
feature/action-registry-observable-migrationbranch - Testing strategy: Update tests incrementally, never break existing tests
- Rollback plan: Each phase can be rolled back independently
- Performance: Observable-based implementation should be comparable or faster
🎯 Next Steps
- Create feature branch:
git checkout -b feature/action-registry-observable-migration - Start with Phase 1 (Type System & Contracts)
- Progress through phases sequentially
- Test after each phase completion
- Create pull request for review
- Merge after approval and final validation
Generated: 2025-10-14 Author: Claude Code (Workflow Generator) Review Status: Pending Technical Review