Skip to content

Command Bus Pattern ​

FancyCRUD is built on the Command Bus Pattern, a powerful architectural pattern that organizes and manages operations within forms and tables. This pattern provides a clean, maintainable, and extensible way to handle application logic.

What is the Command Bus Pattern? ​

The Command Bus Pattern is based on the principle of separation of concerns. Instead of executing operations directly where they originate, commands are sent through a centralized bus that routes them to the appropriate handler.

Think of it like a postal system: you write a letter (command), put it in a mailbox (bus), and the postal service (handler) delivers it to the right address.

Core Components ​

1. Command ​

A command represents the intention to perform an action. It's a simple object containing all necessary data to execute the operation.

ts
// Example: A command to save form data
new SaveFormCommand(formId, fields, settings)

Characteristics:

  • Immutable data container
  • Clearly named (e.g., CreateUserCommand, DeleteRecordCommand)
  • Contains all required parameters
  • No business logic

2. Handler ​

A handler contains the business logic for executing a specific command. Each command type has exactly one handler.

ts
// Handler that processes SaveFormCommand
class SaveFormHandler {
  execute(command: SaveFormCommand) {
    // Business logic here
    const data = generateFormData(command.fields)
    return http.post(command.url, data)
  }
}

Characteristics:

  • Single responsibility (handles one command type)
  • Contains business logic
  • Can inject dependencies
  • Returns results

3. Bus ​

The bus is the central dispatcher that receives commands and routes them to the correct handler.

ts
const bus = new Bus()
const result = bus.execute(new SaveFormCommand(/* ... */))

Characteristics:

  • Single entry point for all commands
  • Type-safe command routing
  • Supports dependency injection
  • Handles handler lifecycle

Why Use Command Bus? ​

✅ Separation of Concerns ​

Commands (what to do) are separate from handlers (how to do it). This makes code easier to understand and maintain.

ts
// ❌ Without Command Bus - Logic mixed with UI
function handleSubmit() {
  const data = { name: form.name, email: form.email }
  axios.post('/users', data)
    .then(response => showNotification('Success'))
    .catch(error => handleError(error))
}

// ✅ With Command Bus - Clean separation
function handleSubmit() {
  bus.execute(new SaveFormCommand(form.id, form.fields, form.settings))
}

✅ Testability ​

Handlers can be tested independently from UI components.

ts
// Easy to test
it('should save form data', () => {
  const handler = new SaveFormHandler()
  const result = handler.execute(new SaveFormCommand(/* test data */))
  expect(result).toBeDefined()
})

✅ Reusability ​

Commands can be executed from anywhere in your application.

ts
// From a component
bus.execute(new SaveFormCommand(form))

// From a composable
bus.execute(new SaveFormCommand(form))

// From another handler
bus.execute(new SaveFormCommand(form))

✅ Extensibility ​

Add new features without modifying existing code.

ts
// Add a new command without touching existing code
class ExportDataCommand { /* ... */ }
class ExportDataHandler { /* ... */ }
register(ExportDataCommand, ExportDataHandler)

✅ Type Safety ​

TypeScript ensures type safety throughout the command flow.

ts
// TypeScript knows the return type
const data: FormData = bus.execute(new GenerateFormDataCommand(fields))

How FancyCRUD Uses Command Bus ​

FancyCRUD uses the Command Bus internally for all operations:

Form Operations:

  • CreateFormCommand - Initialize a new form
  • SaveFormCommand - Submit form data
  • ValidateFormCommand - Validate form fields
  • LoadRemoteRecordCommand - Load data for editing
  • FilterFieldsByFormModeCommand - Filter fields by mode

Table Operations:

  • CreateTableCommand - Initialize a new table
  • FetchListDataCommand - Load table data
  • DeleteRecordCommand - Delete a row
  • PrepareFormToUpdateCommand - Open edit form with data
  • NormalizeColumnsCommand - Process column definitions

Shared Operations:

  • GenerateFormDataCommand - Convert fields to API format
  • SetFieldsErrorsCommand - Set validation errors
  • GetColumnValueCommand - Extract column values

Using Built-in Commands ​

FancyCRUD provides many built-in commands you can use directly:

vue
<template>
  <f-form v-bind="form" />
  
  <!-- Display form data in real-time -->
  <v-card class="mt-4">
    <v-card-title>Form Data (Real-time)</v-card-title>
    <v-card-text>
      <pre>{{ formData }}</pre>
    </v-card-text>
  </v-card>
</template>

<script lang="ts" setup>
import { useForm, Bus, GenerateFormDataCommand } from '@fancy-crud/vue'

const bus = new Bus()

const form = useForm({
  fields: {
    firstName: {
      type: 'text',
      label: 'First Name',
      modelValue: ''
    },
    lastName: {
      type: 'text',
      label: 'Last Name',
      modelValue: ''
    },
    email: {
      type: 'text',
      label: 'Email',
      modelValue: ''
    }
  }
})

// Reactive form data - updates whenever fields change
const formData = computed(() => 
  bus.execute(new GenerateFormDataCommand(form.fields))
)
</script>

This example:

  1. Creates a Bus instance to execute commands
  2. Defines a form with multiple fields
  3. Uses GenerateFormDataCommand to convert field values to API-ready format
  4. Reactively updates the display whenever field values change

Creating Custom Commands ​

You can create your own commands to encapsulate custom business logic. Let's walk through creating a complete custom command.

Step 1: Define the Command ​

Create a command class that implements IBaseCommand:

ts
// commands/GreetCommand.ts
import type { IBaseCommand, Meta } from '@fancy-crud/bus'

export class GreetCommand implements IBaseCommand {
  public readonly $meta: Meta<string>

  constructor(
    public readonly name: string,
    public readonly greeting: string = 'Hello'
  ) {}
}

Key Points:

  • Implement IBaseCommand interface
  • Declare $meta: Meta<T> where T is the return type
  • Use readonly for command properties (commands are immutable)
  • Commands should be data containers only (no business logic)
  • TypeScript will infer the return type from Meta<T>

Step 2: Create the Handler ​

Create a handler class that implements the business logic:

ts
// handlers/GreetHandler.ts
import type { IBaseHandler } from '@fancy-crud/bus'
import type { GreetCommand } from '../commands/GreetCommand'

export class GreetHandler implements IBaseHandler<GreetCommand> {
  execute(command: GreetCommand): string {
    const message = `${command.greeting}, ${command.name}! Welcome to FancyCRUD.`
    return message
  }
}

Key Points:

  • Implement IBaseHandler<TCommand> where TCommand is your command type
  • Implement the execute method with your business logic
  • The return type is automatically inferred from the command's $meta
  • Handlers can inject dependencies (shown below)

Step 3: Register the Handler ​

Register your command-handler pair so the bus knows how to route commands:

ts
// Register in your entry file (main.ts) or plugin
import { register } from '@fancy-crud/bus'
import { GreetCommand } from './commands/GreetCommand'
import { GreetHandler } from './handlers/GreetHandler'

register(GreetCommand, GreetHandler)

Best Practice:

  • Register commands in your application's entry point (main.ts)
  • Or create a dedicated commands.ts file imported at startup
  • Avoid registering in components (unless absolutely necessary)

WARNING

The register() function must be called before attempting to execute the command. Otherwise, you'll get:

HandlerDoesNotExist: Handler not found for command: GreetCommand

Step 4: Use the Command ​

Now you can execute your command from anywhere in your application:

vue
<template>
  <f-form v-bind="form" />
  
  <v-card class="mt-4">
    <v-card-title>Greeting Message</v-card-title>
    <v-card-text>
      <p v-if="greetingMessage">{{ greetingMessage }}</p>
      <p v-else>Enter your name and click the button</p>
    </v-card-text>
  </v-card>
</template>

<script lang="ts" setup>
import { ref } from 'vue'
import { useForm, Bus, register } from '@fancy-crud/vue'
import { GreetCommand } from './commands/GreetCommand'
import { GreetHandler } from './handlers/GreetHandler'

// Register the command-handler pair
register(GreetCommand, GreetHandler)

const bus = new Bus()
const greetingMessage = ref('')

const form = useForm({
  fields: {
    name: {
      type: 'text',
      label: 'Your Name',
      modelValue: '',
      placeholder: 'Enter your name...'
    },
    greeting: {
      type: 'select',
      label: 'Greeting Type',
      modelValue: 'Hello',
      options: [
        { label: 'Hello', value: 'Hello' },
        { label: 'Hi', value: 'Hi' },
        { label: 'Welcome', value: 'Welcome' },
        { label: 'Greetings', value: 'Greetings' },
      ]
    }
  },
  buttons: {
    main: {
      label: 'Say Hello!',
      onClick: sayHello,
    },
  },
})

function sayHello() {
  // Execute the command
  greetingMessage.value = bus.execute(
    new GreetCommand(
      form.fields.name.modelValue,
      form.fields.greeting.modelValue
    )
  )
}
</script>

Advanced Patterns ​

Commands with Dependencies ​

Handlers can inject dependencies through their constructor:

ts
// Command
export class SendEmailCommand implements IBaseCommand {
  public readonly $meta: Meta<Promise<boolean>>

  constructor(
    public readonly to: string,
    public readonly subject: string,
    public readonly body: string
  ) {}
}

// Handler with dependencies
export class SendEmailHandler implements IBaseHandler<SendEmailCommand> {
  constructor(
    private emailService: EmailService,
    private logger: Logger
  ) {}

  execute(command: SendEmailCommand): Promise<boolean> {
    this.logger.info(`Sending email to ${command.to}`)
    return this.emailService.send({
      to: command.to,
      subject: command.subject,
      body: command.body,
    })
  }
}

// Register with dependencies
register(SendEmailCommand, SendEmailHandler)

// Execute with providers
const result = bus.execute(
  new SendEmailCommand('user@example.com', 'Hello', 'Message body'),
  [emailServiceInstance, loggerInstance]
)

Async Commands ​

Commands can return promises for async operations:

ts
// Async command
export class FetchUserCommand implements IBaseCommand {
  public readonly $meta: Meta<Promise<User>>

  constructor(
    public readonly userId: number
  ) {}
}

// Async handler
export class FetchUserHandler implements IBaseHandler<FetchUserCommand> {
  async execute(command: FetchUserCommand): Promise<User> {
    const response = await axios.get(`/users/${command.userId}`)
    return response.data
  }
}

// Usage with await
const user = await bus.execute(new FetchUserCommand(123))
console.log(user.name)

Command Composition ​

Commands can execute other commands within handlers:

ts
export class CreateUserWithRoleHandler implements IBaseHandler<CreateUserWithRoleCommand> {
  execute(command: CreateUserWithRoleCommand): User {
    const bus = new Bus()
    
    // Execute CreateUserCommand
    const user = bus.execute(
      new CreateUserCommand(command.userData)
    )
    
    // Execute AssignRoleCommand
    bus.execute(
      new AssignRoleCommand(user.id, command.roleId)
    )
    
    return user
  }
}

Command Middleware Pattern ​

Create reusable logic that wraps command execution:

ts
// Logging middleware
function withLogging<T extends IBaseCommand>(command: T) {
  const bus = new Bus()
  
  console.log(`Executing: ${command.constructor.name}`)
  const startTime = Date.now()
  
  try {
    const result = bus.execute(command)
    const duration = Date.now() - startTime
    console.log(`Success: ${command.constructor.name} (${duration}ms)`)
    return result
  } catch (error) {
    console.error(`Error: ${command.constructor.name}`, error)
    throw error
  }
}

// Usage
const result = withLogging(new SaveFormCommand(form))

Real-World Examples ​

Example 1: Export Data Command ​

ts
// Command
export class ExportTableDataCommand implements IBaseCommand {
  public readonly $meta: Meta<Blob>

  constructor(
    public readonly data: any[],
    public readonly format: 'csv' | 'xlsx' | 'pdf',
    public readonly filename: string
  ) {}
}

// Handler
export class ExportTableDataHandler implements IBaseHandler<ExportTableDataCommand> {
  execute(command: ExportTableDataCommand): Blob {
    switch (command.format) {
      case 'csv':
        return this.exportToCSV(command.data, command.filename)
      case 'xlsx':
        return this.exportToExcel(command.data, command.filename)
      case 'pdf':
        return this.exportToPDF(command.data, command.filename)
    }
  }

  private exportToCSV(data: any[], filename: string): Blob {
    const csv = this.convertToCSV(data)
    return new Blob([csv], { type: 'text/csv' })
  }

  private convertToCSV(data: any[]): string {
    // CSV conversion logic
    const headers = Object.keys(data[0]).join(',')
    const rows = data.map(row => Object.values(row).join(',')).join('\n')
    return `${headers}\n${rows}`
  }

  // ... other export methods
}

// Usage
const table = useTable({ /* ... */ })
const bus = new Bus()

function exportData(format: 'csv' | 'xlsx' | 'pdf') {
  const blob = bus.execute(
    new ExportTableDataCommand(
      table.list.data,
      format,
      `users_export_${Date.now()}.${format}`
    )
  )
  
  // Download the file
  const url = URL.createObjectURL(blob)
  const link = document.createElement('a')
  link.href = url
  link.download = `export.${format}`
  link.click()
}

Example 2: Batch Operations Command ​

ts
// Command for batch operations
export class BatchDeleteCommand implements IBaseCommand {
  public readonly $meta: Meta<Promise<{ success: number; failed: number }>>

  constructor(
    public readonly ids: number[],
    public readonly url: string
  ) {}
}

// Handler
export class BatchDeleteHandler implements IBaseHandler<BatchDeleteCommand> {
  async execute(command: BatchDeleteCommand) {
    const results = await Promise.allSettled(
      command.ids.map(id => 
        axios.delete(`${command.url}${id}/`)
      )
    )

    const success = results.filter(r => r.status === 'fulfilled').length
    const failed = results.filter(r => r.status === 'rejected').length

    return { success, failed }
  }
}

// Usage
const selectedRows = ref([1, 2, 3, 4, 5])

async function deleteSelected() {
  const { success, failed } = await bus.execute(
    new BatchDeleteCommand(selectedRows.value, 'users/')
  )

  console.log(`Deleted ${success} records, ${failed} failed`)
  
  // Refresh table
  table.list.fetchData()
}

Example 3: Form Data Validation Command ​

ts
interface ValidationResult {
  valid: boolean
  errors: Record<string, string[]>
}

// Command
export class ValidateFormDataCommand implements IBaseCommand {
  public readonly $meta: Meta<ValidationResult>

  constructor(
    public readonly data: Record<string, any>,
    public readonly rules: ValidationRules
  ) {}
}

// Handler
export class ValidateFormDataHandler implements IBaseHandler<ValidateFormDataCommand> {
  execute(command: ValidateFormDataCommand): ValidationResult {
    const errors: Record<string, string[]> = {}

    for (const [field, rules] of Object.entries(command.rules)) {
      const value = command.data[field]
      const fieldErrors: string[] = []

      for (const rule of rules) {
        const result = rule.validate(value)
        if (result !== true) {
          fieldErrors.push(result)
        }
      }

      if (fieldErrors.length > 0) {
        errors[field] = fieldErrors
      }
    }

    return {
      valid: Object.keys(errors).length === 0,
      errors,
    }
  }
}

// Usage
const validationRules = {
  email: [
    { validate: (v) => /.+@.+/.test(v) || 'Invalid email' },
    { validate: (v) => v.length > 0 || 'Email is required' },
  ],
  age: [
    { validate: (v) => v >= 18 || 'Must be 18 or older' },
  ],
}

const result = bus.execute(
  new ValidateFormDataCommand(form.fields, validationRules)
)

if (!result.valid) {
  console.log('Validation errors:', result.errors)
}

Best Practices ​

1. Command Naming ​

Use descriptive, action-oriented names that clearly state intent:

ts
// ✅ Good
CreateUserCommand
DeleteRecordCommand
SendEmailNotificationCommand
ExportTableDataCommand

// ❌ Bad
UserCommand
RecordCommand
EmailCommand
DataCommand

2. Keep Commands Simple ​

Commands should be data containers only:

ts
// ✅ Good - Just data
export class UpdateUserCommand implements IBaseCommand {
  public readonly $meta: Meta<User>

  constructor(
    public readonly userId: number,
    public readonly updates: Partial<User>
  ) {}
}

// ❌ Bad - Contains logic
export class UpdateUserCommand implements IBaseCommand {
  public readonly $meta: Meta<User>

  constructor(
    public readonly userId: number,
    public readonly updates: Partial<User>
  ) {
    // Don't do validation or transformations here
    if (!this.userId) throw new Error('Invalid ID') // ❌ No validation!
    this.updates = this.sanitize(updates) // ❌ No logic!
  }
}

3. Single Responsibility for Handlers ​

Each handler should do one thing well:

ts
// ✅ Good - Focused responsibility
export class SaveUserHandler implements IBaseHandler<SaveUserCommand> {
  execute(command: SaveUserCommand): Promise<User> {
    return axios.post('/users', command.data)
  }
}

// ❌ Bad - Too many responsibilities
export class SaveUserHandler extends IBaseHandler<SaveUserCommand> {
  execute(command: SaveUserCommand): Promise<User> {
    // Validate
    this.validate(command.data)
    // Transform
    const transformed = this.transform(command.data)
    // Save
    const user = await axios.post('/users', transformed)
    // Send email
    await this.sendWelcomeEmail(user)
    // Log activity
    await this.logActivity(user)
    return user
  }
}

Break complex operations into multiple commands:

ts
// ✅ Better - Separate commands
bus.execute(new SaveUserCommand(data))
bus.execute(new SendWelcomeEmailCommand(user.email))
bus.execute(new LogActivityCommand(user.id, 'user_created'))

4. Use TypeScript Generics ​

Let TypeScript infer return types automatically:

ts
// ✅ Good - Type-safe
export class FetchUserCommand implements IBaseCommand {
  public readonly $meta: Meta<Promise<User>>

  constructor(
    public readonly id: number
  ) {}
}

// TypeScript knows this returns Promise<User>
const user = await bus.execute(new FetchUserCommand(1))
user.name // ✅ Type-safe

5. Register Commands at App Startup ​

Don't register in components:

ts
// ✅ Good - main.ts or commands.ts
import { register } from '@fancy-crud/bus'
import * as commands from './commands'
import * as handlers from './handlers'

register(commands.SaveUserCommand, handlers.SaveUserHandler)
register(commands.DeleteUserCommand, handlers.DeleteUserHandler)
// ... all commands

// ❌ Bad - component
<script setup>
// Don't register here unless you have a very good reason
register(SaveUserCommand, SaveUserHandler)
</script>

6. Handle Errors Gracefully ​

Always handle potential errors in handlers:

ts
export class SaveFormHandler implements IBaseHandler<SaveFormCommand> {
  async execute(command: SaveFormCommand) {
    try {
      const response = await axios.post(command.url, command.data)
      return response.data
    } catch (error) {
      if (error.response?.status === 422) {
        // Handle validation errors
        throw new ValidationError(error.response.data.errors)
      }
      // Re-throw other errors
      throw error
    }
  }
}

Troubleshooting ​

Error: Handler not found for command ​

Problem:

HandlerDoesNotExist: Handler not found for command: MyCommand

Solutions:

  1. Ensure you've called register(MyCommand, MyHandler)
  2. Registration must happen before execution
  3. Check that the handler is imported correctly
  4. Verify the command class name matches

TypeScript: Type errors with command execution ​

Problem:

ts
const result = bus.execute(new MyCommand())
// Type 'unknown' is not assignable to...

Solution: Ensure your command implements IBaseCommand with $meta: Meta<ReturnType>:

ts
// ✅ Correct
export class MyCommand implements IBaseCommand {
  public readonly $meta: Meta<string>

  constructor(
    public readonly name: string
  ) {}
}

Commands not working in production ​

Problem: Commands work in development but fail in production build.

Solutions:

  1. Ensure all commands are registered before app mounts
  2. Check that commands/handlers are properly imported (not tree-shaken)
  3. Verify class names aren't being mangled by minification

Performance Considerations ​

Command Pooling ​

For frequently executed commands, consider object pooling:

ts
class CommandPool<T> {
  private pool: T[] = []

  get(): T | undefined {
    return this.pool.pop()
  }

  return(item: T) {
    this.pool.push(item)
  }
}

// Reuse command instances
const pool = new CommandPool<FetchDataCommand>()
const command = pool.get() || new FetchDataCommand()
bus.execute(command)
pool.return(command)

Memoization ​

Cache command results for expensive operations:

ts
const cache = new Map()

function executeWithCache<T extends IBaseCommand>(command: T) {
  const key = JSON.stringify(command)
  
  if (cache.has(key)) {
    return cache.get(key)
  }

  const result = bus.execute(command)
  cache.set(key, result)
  return result
}

Next Steps ​