Writing Handlers
Writing Handlers
Section titled “Writing Handlers”Handlers are the core of your service - they define how your service responds to commands, queries, and events. The platform automatically discovers handlers and generates APIs from them.
Use This Guide If…
Section titled “Use This Guide If…”- You’re implementing business logic for commands (write operations)
- You’re creating queries to retrieve data (read operations)
- You’re building event handlers to react to domain events
- You want to understand handler discovery and dependency injection
- You need to integrate cross-service communication
Handler Types
Section titled “Handler Types”The platform supports three types of handlers:
- Command Handlers: Write operations (create, update, delete)
- Query Handlers: Read operations (get, find, list, search)
- Event Subscription Handlers: React to events from other services or aggregates
Each handler type has specific conventions and patterns.
Command Handlers
Section titled “Command Handlers”Commands represent write operations that modify state.
Basic Command Handler
Section titled “Basic Command Handler”import { CommandHandler, CommandHandlerDecorator } from '@banyanai/platform-base-service';import type { AuthenticatedUser } from '@banyanai/platform-core';import { Logger } from '@banyanai/platform-telemetry';import { CreateUserCommand, type CreateUserResult } from '../contracts/commands.js';
@CommandHandlerDecorator(CreateUserCommand)export class CreateUserHandler extends CommandHandler<CreateUserCommand, CreateUserResult> { constructor() { super(); }
async handle(command: CreateUserCommand, user: AuthenticatedUser | null): Promise<CreateUserResult> { Logger.info('Creating user', { email: command.email });
const userId = `user-${Date.now()}`;
return { userId, email: command.email, createdAt: new Date().toISOString(), }; }}Automatically generates:
- REST:
POST /api/create-user - GraphQL:
mutation { createUser(input: ...) { ... } } - Message bus: Subscribes to
CreateUserCommand
Command Handler with Service Clients
Section titled “Command Handler with Service Clients”import { NotificationServiceClient } from '../clients/NotificationServiceClient.js';import { AuditServiceClient } from '../clients/AuditServiceClient.js';
@CommandHandlerDecorator(CreateUserCommand)export class CreateUserHandler extends CommandHandler<CreateUserCommand, CreateUserResult> { constructor( private readonly notifications: NotificationServiceClient, private readonly audit: AuditServiceClient ) { super(); }
async handle(command: CreateUserCommand, user: AuthenticatedUser | null): Promise<CreateUserResult> { const userId = `user-${Date.now()}`;
// Cross-service calls (automatically traced) await this.audit.logUserCreation(userId, command.email, user!.userId); await this.notifications.sendWelcomeEmail(userId, command.email);
return { userId, email: command.email, createdAt: new Date().toISOString() }; }}Command Handler with Event Sourcing
Section titled “Command Handler with Event Sourcing”import { UserAggregate } from '../domain/UserAggregate.js';
@CommandHandlerDecorator(CreateUserCommand)export class CreateUserHandler extends CommandHandler<CreateUserCommand, CreateUserResult> { constructor() { super(); }
async handle(command: CreateUserCommand, user: AuthenticatedUser | null): Promise<CreateUserResult> { // Create aggregate (generates domain events) const userAggregate = UserAggregate.create(command, user!.userId);
// Save (publishes domain events automatically) await this.save(userAggregate);
return { userId: userAggregate.id, email: command.email, createdAt: new Date().toISOString(), }; }}Query Handlers
Section titled “Query Handlers”Queries represent read operations that retrieve data from read models.
Basic Query Handler
Section titled “Basic Query Handler”import { QueryHandler, QueryHandlerDecorator } from '@banyanai/platform-base-service';import type { AuthenticatedUser } from '@banyanai/platform-core';import { Logger } from '@banyanai/platform-telemetry';import { GetUserQuery, type UserResult } from '../contracts/queries.js';import { UserReadModel } from '../read-models/UserReadModel.js';
@QueryHandlerDecorator(GetUserQuery)export class GetUserHandler extends QueryHandler<GetUserQuery, UserResult> { constructor() { super(); }
async handle(query: GetUserQuery, user: AuthenticatedUser | null): Promise<UserResult> { Logger.info('Getting user', { userId: query.userId });
// Query read model using static method const userModel = await UserReadModel.findById<UserReadModel>(query.userId);
if (!userModel) { throw new Error('User not found'); }
return { userId: userModel.id, email: userModel.email, firstName: userModel.profile.firstName, lastName: userModel.profile.lastName, createdAt: userModel.createdAt, }; }}Automatically generates:
- REST:
GET /api/get-user?userId=123 - GraphQL:
query { getUser(input: { userId: "123" }) { ... } } - Message bus: Subscribes to
GetUserQuery
Query Handler with Pagination
Section titled “Query Handler with Pagination”@QueryHandlerDecorator(ListUsersQuery)export class ListUsersHandler extends QueryHandler<ListUsersQuery, ListUsersResult> { constructor() { super(); }
async handle(query: ListUsersQuery, user: AuthenticatedUser | null): Promise<ListUsersResult> { const page = query.page || 1; const pageSize = query.pageSize || 20;
// Query read model with filters const users = await UserReadModel.findBy<UserReadModel>({ role: query.role, status: query.status, });
// Apply pagination const start = (page - 1) * pageSize; const paginatedUsers = users.slice(start, start + pageSize);
return { users: paginatedUsers, page, pageSize, totalCount: users.length, totalPages: Math.ceil(users.length / pageSize), }; }}Event Subscription Handlers
Section titled “Event Subscription Handlers”Event handlers react to events published by other services or aggregates.
Important: Event handlers must be in src/subscriptions/ (NOT src/events/)
Basic Event Handler
Section titled “Basic Event Handler”import { EventSubscriptionHandler, EventHandlerDecorator } from '@banyanai/platform-base-service';import type { AuthenticatedUser } from '@banyanai/platform-core';import { Logger } from '@banyanai/platform-telemetry';import { UserCreatedEvent } from '@myorg/user-service-contracts';
@EventHandlerDecorator(UserCreatedEvent)export class UserCreatedHandler extends EventSubscriptionHandler<UserCreatedEvent> { constructor() { super(); }
async handle(event: UserCreatedEvent, user: AuthenticatedUser | null): Promise<void> { Logger.info('User created event received', { userId: event.userId, email: event.email, });
// Update read models, trigger workflows, etc. }}Automatically:
- Subscribes to
UserCreatedEventon message bus - Receives events when users are created
- Can push events to WebSocket subscribers
- Can trigger GraphQL subscriptions
Event Handler with Service Clients
Section titled “Event Handler with Service Clients”import { WelcomeEmailServiceClient } from '../clients/WelcomeEmailServiceClient.js';
@EventHandlerDecorator(UserCreatedEvent)export class UserCreatedHandler extends EventSubscriptionHandler<UserCreatedEvent> { constructor( private readonly welcomeEmail: WelcomeEmailServiceClient ) { super(); }
async handle(event: UserCreatedEvent, user: AuthenticatedUser | null): Promise<void> { // Send welcome email when user is created await this.welcomeEmail.sendWelcomeEmail( event.userId, event.email, event.firstName ); }}Handler Discovery
Section titled “Handler Discovery”The platform automatically discovers handlers based on:
1. File Location
Section titled “1. File Location”- Commands:
src/commands/ - Queries:
src/queries/ - Events:
src/subscriptions/(NOTsrc/events/)
2. File Naming
Section titled “2. File Naming”Files must end with Handler.ts:
CreateUserHandler.ts ✅GetUserHandler.ts ✅UserCreatedHandler.ts ✅
CreateUser.ts ❌ Missing Handler suffixhandlers/CreateUser.ts ❌ Wrong directory3. Decorator
Section titled “3. Decorator”Must have appropriate decorator with class constructor (not string):
@CommandHandlerDecorator(CreateUserCommand) ✅ Class constructor@CommandHandlerDecorator('CreateUser') ❌ String name4. Base Class
Section titled “4. Base Class”Must extend appropriate base class:
export class CreateUserHandler extends CommandHandler<...> ✅export class GetUserHandler extends QueryHandler<...> ✅export class UserCreatedHandler extends EventSubscriptionHandler<...> ✅Constructor Dependency Injection
Section titled “Constructor Dependency Injection”Allowed Dependencies
Section titled “Allowed Dependencies”// No dependenciesconstructor() { super();}
// Only ServiceClient dependencies (automatically injected)constructor( private readonly notifications: NotificationServiceClient, private readonly audit: AuditServiceClient) { super();}Not Allowed
Section titled “Not Allowed”// ❌ Repositories (use ReadModel static methods instead)constructor(private readonly userRepo: UserRepository) {}
// ❌ Policies (use @RequirePolicy decorator)constructor(private readonly policy: CreateUserPolicy) {}
// ❌ Logger (use static import: Logger.info())constructor(private readonly logger: Logger) {}
// ❌ Interfaces (use concrete ServiceClient classes)constructor(private readonly client: INotificationClient) {}Data Access Patterns
Section titled “Data Access Patterns”Reading Data (Queries)
Section titled “Reading Data (Queries)”Use ReadModel static methods:
// Find by IDconst user = await UserReadModel.findById<UserReadModel>(userId);
// Find by criteriaconst users = await UserReadModel.findBy<UserReadModel>({ role: 'admin', isActive: true});
// List allconst allUsers = await UserReadModel.list<UserReadModel>();Writing Data (Commands)
Section titled “Writing Data (Commands)”Use aggregates with event sourcing:
// Create new aggregateconst aggregate = UserAggregate.create(command, user.userId);await this.save(aggregate);
// Load and modify existing aggregateconst aggregate = await this.getAggregate<UserAggregate>( UserAggregate, command.userId);aggregate.updateEmail(command.newEmail);await this.save(aggregate);Error Handling
Section titled “Error Handling”async handle(command: CreateUserCommand, user: AuthenticatedUser | null): Promise<CreateUserResult> { try { // Business logic return result; } catch (error) { Logger.error('Failed to create user', { error: error.message, command, });
// Re-throw for platform error handling throw error; }}The platform automatically:
- Logs errors with correlation IDs
- Returns structured error responses
- Records telemetry metrics
- Rolls back transactions
Validation
Section titled “Validation”Input Validation
Section titled “Input Validation”async handle(command: CreateUserCommand, user: AuthenticatedUser | null): Promise<CreateUserResult> { // Validate email if (!command.email || !command.email.includes('@')) { throw new Error('Invalid email address'); }
// Validate business rules if (command.age && command.age < 18) { throw new Error('User must be 18 or older'); }
// Business logic...}Contract Validation
Section titled “Contract Validation”Define validation in contracts (see Defining Contracts):
@Command({ description: 'Create a new user account', permissions: ['users:create'],})export class CreateUserCommand { email!: string; // Required age?: number; // Optional}Best Practices
Section titled “Best Practices”Command Handlers
Section titled “Command Handlers”DO:
- Keep handlers focused on one operation
- Use aggregates for complex business logic
- Publish domain events for side effects
- Use ServiceClients for cross-service calls
DON’T:
- Query data in command handlers (use event sourcing)
- Call multiple services synchronously without reason
- Handle complex orchestration (use sagas)
Query Handlers
Section titled “Query Handlers”DO:
- Query read models (optimized for reads)
- Add pagination for lists
- Return only needed data
- Include user parameter for authorization context
DON’T:
- Modify data (queries are read-only)
- Call other services (use direct DB access)
- Include business logic (queries are data retrieval)
Event Subscription Handlers
Section titled “Event Subscription Handlers”DO:
- Update read models
- Trigger workflows
- Send notifications
- Keep handlers idempotent
DON’T:
- Process the same event twice
- Fail silently (log errors)
- Block event processing (use async)
Testing Handlers
Section titled “Testing Handlers”import { CreateUserHandler } from './CreateUserHandler.js';import { CreateUserCommand } from '../contracts/commands.js';
describe('CreateUserHandler', () => { it('should create user successfully', async () => { const handler = new CreateUserHandler(); const command = new CreateUserCommand(); command.email = 'test@example.com'; command.firstName = 'Test'; command.lastName = 'User';
const user = { userId: 'admin-123', permissions: ['users:create'] };
const result = await handler.handle(command, user);
expect(result.userId).toBeDefined(); expect(result.email).toBe('test@example.com'); });});See Testing Services for comprehensive testing patterns.
Troubleshooting
Section titled “Troubleshooting””Handler not discovered”
Section titled “”Handler not discovered””- Check file is in correct directory (
src/commands/,src/queries/,src/subscriptions/) - Check filename ends with
Handler.ts - Check decorator is present and uses class constructor
- Check class extends correct base class
”Unsatisfiable parameters”
Section titled “”Unsatisfiable parameters””- Use concrete ServiceClient classes (not interfaces)
- ServiceClient class names must end with
ServiceClient - Only ServiceClients allowed in constructor
”User parameter not provided”
Section titled “”User parameter not provided””All handlers require the user: AuthenticatedUser | null parameter even if not used. This provides the authentication context for the request.
Related Resources
Section titled “Related Resources”- Defining Contracts - Creating type-safe service contracts
- Using Service Clients - Cross-service communication
- Data Access Patterns - Working with aggregates and read models
- Testing Services - Testing strategies and patterns
Next Steps:
- Define your service contracts
- Implement handlers for each contract
- Add service client dependencies as needed
- Test your handlers thoroughly