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.
// 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.
// 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.
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.
// ❌ 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.
// 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.
// 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.
// 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.
// 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 formSaveFormCommand
- Submit form dataValidateFormCommand
- Validate form fieldsLoadRemoteRecordCommand
- Load data for editingFilterFieldsByFormModeCommand
- Filter fields by mode
Table Operations:
CreateTableCommand
- Initialize a new tableFetchListDataCommand
- Load table dataDeleteRecordCommand
- Delete a rowPrepareFormToUpdateCommand
- Open edit form with dataNormalizeColumnsCommand
- Process column definitions
Shared Operations:
GenerateFormDataCommand
- Convert fields to API formatSetFieldsErrorsCommand
- Set validation errorsGetColumnValueCommand
- Extract column values
Using Built-in Commands
FancyCRUD provides many built-in commands you can use directly:
<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:
- Creates a
Bus
instance to execute commands - Defines a form with multiple fields
- Uses
GenerateFormDataCommand
to convert field values to API-ready format - 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
:
// 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>
whereT
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:
// 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>
whereTCommand
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:
// 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:
<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:
// 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:
// 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:
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:
// 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
// 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
// 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
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:
// ✅ Good
CreateUserCommand
DeleteRecordCommand
SendEmailNotificationCommand
ExportTableDataCommand
// ❌ Bad
UserCommand
RecordCommand
EmailCommand
DataCommand
2. Keep Commands Simple
Commands should be data containers only:
// ✅ 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:
// ✅ 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:
// ✅ 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:
// ✅ 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:
// ✅ 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:
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:
- Ensure you've called
register(MyCommand, MyHandler)
- Registration must happen before execution
- Check that the handler is imported correctly
- Verify the command class name matches
TypeScript: Type errors with command execution
Problem:
const result = bus.execute(new MyCommand())
// Type 'unknown' is not assignable to...
Solution: Ensure your command implements IBaseCommand
with $meta: Meta<ReturnType>
:
// ✅ 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:
- Ensure all commands are registered before app mounts
- Check that commands/handlers are properly imported (not tree-shaken)
- Verify class names aren't being mangled by minification
Performance Considerations
Command Pooling
For frequently executed commands, consider object pooling:
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:
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
- Explore Form Commands for all available form operations
- Learn about Table Commands for table-specific commands
- Check out the Configuration Guide for setting up FancyCRUD