Instructions topic

How to Use Instructions in the Cell Framework

Table of Contents

  1. Introduction
  2. What is an Instruction?
  3. Core Concepts
  4. Creating Instructions
  5. Composing Pipelines
  6. Using Instructions with Receptors
  7. Asynchronous Instructions
  8. Testing Instructions
  9. Error Handling
  10. Best Practices
  11. Complete Example

Introduction

Instructions are the fundamental building blocks for data transformation in the Cell Framework. They represent reusable, composable units of logic that process pulses through a reactive graph. Think of them as pipeline stages that transform data as it flows from one cell to another.

When to Use Instructions

  • Data Validation - Validate incoming data before processing
  • Data Transformation - Convert data from one format to another
  • Data Enrichment - Add additional data to existing payloads
  • Filtering - Drop unwanted pulses
  • Logging - Audit trail for debugging
  • Sanitization - Clean and normalize data

What is an Instruction?

An Instruction is a stateless transformation unit that takes a Pulse as input and returns either:

  • A new Pulse (for successful transformation)
  • null (to terminate/filter the signal)

Type Parameters

Instruction<C extends Cell, I extends Pulse, O extends Pulse>
Parameter Description
C The type of the host Cell (usually Cell)
I The type of the input Pulse (usually Pulse)
O The type of the output Pulse (usually Pulse)

For most cases, you'll use Instruction<Cell, Pulse, Pulse>.


Core Concepts

1. Pulses are Immutable

Instructions never modify the input pulse. They always create and return a new pulse with the transformed data.

// ❌ WRONG - Modifies input
static Pulse? _process(Pulse pulse) {
  pulse.payload = newData;  // NO - Pulse is immutable!
  return pulse;
}

// ✅ CORRECT - Returns new pulse
static Pulse? _process(Pulse pulse) {
  final newData = transform(pulse.payload);
  return Pulse(newData);  // Returns a NEW pulse
}

2. Instructions are Stateless

Instructions should not hold internal state. All state should be managed by the surrounding Cell or passed through the pulse.

// ❌ WRONG - Holds state
class CounterInstruction extends InstructionBase {
  int _count = 0;  // NO - Instructions should be stateless!
  // ...
}

// ✅ CORRECT - Stateless
Instruction<Cell, Pulse, Pulse> counterInstruction() {
  return Instruction((pulse, {cell, user}) {
    // All state is in the pulse
    final count = pulse.payload as int? ?? 0;
    return Pulse(count + 1);
  });
}

3. Instructions are Composable

Instructions can be chained together using the + operator to create complex pipelines.

final pipeline = sanitize + validate + enrich + format;

Creating Instructions

This is the simplest way to create an instruction.

Instruction<Cell, Pulse, Pulse> sanitizeInstruction() {
  return Instruction<Cell, Pulse, Pulse>(
    (pulse, {cell, user}) {
      final data = pulse.payload as Map<String, dynamic>?;
      if (data == null) {
        print('No data to sanitize');
        return null;  // Terminate the signal
      }
      
      // Transform the data
      final sanitized = _sanitizeData(data);
      
      // Return a new pulse
      return Pulse(sanitized);
    },
  );
}

Method 2: Using a Static Function

class SanitizeInstruction {
  static Pulse? process(Pulse pulse, {Cell? cell, dynamic user}) {
    final data = pulse.payload as Map<String, dynamic>?;
    if (data == null) return null;
    
    final sanitized = _sanitizeData(data);
    return Pulse(sanitized);
  }
  
  static Instruction<Cell, Pulse, Pulse> create() {
    return Instruction<Cell, Pulse, Pulse>(process);
  }
}

Method 3: With User Data

Instruction<Cell, Pulse, Pulse> thresholdInstruction({required int limit}) {
  return Instruction<Cell, Pulse, Pulse>(
    (pulse, {cell, user}) {
      final value = pulse.payload as int? ?? 0;
      if (value > limit) {
        return Pulse(value);  // Pass through
      }
      return null;  // Filter out
    },
    user: limit,  // Pass configuration
  );
}

Composing Pipelines

Using the + Operator

The + operator chains instructions sequentially:

final sanitize = sanitizeInstruction();
final validate = validateInstruction();
final enrich = enrichInstruction();

// Pipeline: sanitize → validate → enrich
final pipeline = sanitize + validate + enrich;

Execution Order

Instructions are executed left to right:

// Step 1: sanitize runs first
// Step 2: validate runs with sanitize's output
// Step 3: enrich runs with validate's output
final pipeline = sanitize + validate + enrich;

Short-Circuiting

If any instruction returns null, the pipeline stops immediately:

// If validate returns null, enrich is NEVER called
final pipeline = sanitize + validate + enrich;
//          ↑           ↑           ↑
//      runs        if passes    runs

Complex Pipelines

// Multi-stage pipeline with grouping
final validationPipeline = validateEmail + validatePhone + validateAddress;
final enrichmentPipeline = enrichCustomer + enrichPricing + enrichShipping;
final fullPipeline = sanitize + validationPipeline + enrichmentPipeline + format + log;

Using Instructions with Receptors

Creating a Receptor from an Instruction

final instruction = sanitizeInstruction();
final receptor = Receptor.instruction(instruction);

Creating a Multi-Stage Receptor

final receptor = Receptor.pipeline(
  preProcess: sanitizeInstruction(),
  instruction: validateInstruction() + processInstruction(),
  postProcess: enrichInstruction(),
);

Using a Receptor in a Cell

final receptor = Receptor.instruction(sanitizeInstruction());
final cell = Cell(
  receptor: receptor,
  context: Context.module('data-processing'),
);

Complete Example

// 1. Create instructions
final sanitize = sanitizeInstruction();
final validate = validateInstruction();
final process = processInstruction();
final log = logInstruction();

// 2. Build pipeline
final pipeline = sanitize + validate + process + log;

// 3. Create receptor
final receptor = Receptor.instruction(pipeline);

// 4. Create cell
final cell = Cell(
  receptor: receptor,
  context: Context.module('order-processing'),
);

// 5. Process data
final result = cell._nucleus.receptor.call(Pulse(rawData));

Asynchronous Instructions

Creating Async Instructions

Use Instruction.future() for async operations:

Instruction<Cell, Pulse, Pulse> fetchCustomerData() {
  return Instruction<Cell, Pulse, Pulse>.future(
    (pulse, {cell, future, token, user}) {
      final customerId = pulse.payload as String?;
      if (customerId == null) return null;
      
      // Async operation
      fetchFromDatabase(customerId).then((data) {
        // Continue the pipeline with the result
        future?.call(
          result: Pulse(data),
          token: token,
        );
      });
      
      return null;  // Return immediately, continue later
    },
  );
}

Async Pipeline Example

final pipeline = validate + fetchCustomerData + enrich + format;

Waiting for Completion

final result = await pipeline.call(Pulse(customerId));

Combining Sync and Async

Sync and async instructions can be freely mixed:

// Sync → Async → Sync
final pipeline = validate + fetchData + format + log;
//        ↑          ↑          ↑        ↑
//     sync      async      sync     sync

Testing Instructions

Unit Testing Individual Instructions

import 'package:test/test.dart';

void main() {
  test('SanitizeInstruction handles valid data', () {
    final instruction = sanitizeInstruction();
    final input = Pulse({
      'id': 'ORD-123',
      'customer': '  John Doe  ',
      'email': 'JOHN@EXAMPLE.COM',
      'items': '2',
      'total': '99.99',
    });
    
    final result = instruction.call(input);
    
    expect(result, isNotNull);
    final data = result!.payload as Map<String, dynamic>;
    expect(data['customer'], 'John Doe');
    expect(data['email'], 'john@example.com');
    expect(data['items'], 2);
    expect(data['total'], 99.99);
  });
  
  test('SanitizeInstruction handles invalid data', () {
    final instruction = sanitizeInstruction();
    final input = Pulse({
      'id': '',
      'customer': '  John Doe  ',
      'email': 'john@example.com',
    });
    
    final result = instruction.call(input);
    expect(result, isNull);  // Invalid data should return null
  });
}

Testing Pipelines

test('Full pipeline processes order correctly', () {
  final pipeline = sanitize + validate + enrich + format + log;
  final input = Pulse(rawOrderData);
  
  final result = pipeline.call(input);
  
  expect(result, isNotNull);
  expect(result!.payload, isA<Order>());
  final order = result.payload as Order;
  expect(order.status, 'ready');
  expect(order.customer.isVip, true);
});

Testing with Mock Data

test('Async instruction handles network errors', () async {
  final instruction = fetchCustomerData();
  final completer = Completer<Pulse?>();
  
  instruction.call(
    Pulse('invalid-id'),
    future: ({result, token}) {
      completer.complete(result);
    },
  );
  
  final result = await completer.future;
  expect(result, isNull);  // Error should return null
});

Error Handling

1. Returning null (Filtering)

Instruction<Cell, Pulse, Pulse> positiveNumberFilter() {
  return Instruction((pulse, {cell, user}) {
    final value = pulse.payload as int?;
    if (value == null || value <= 0) {
      print('Negative/zero value filtered: $value');
      return null;  // Drop the pulse
    }
    return pulse;
  });
}

2. Throwing Exceptions

Instruction<Cell, Pulse, Pulse> strictValidation() {
  return Instruction((pulse, {cell, user}) {
    final data = pulse.payload as Map<String, dynamic>?;
    if (data == null) {
      throw ArgumentError('Data cannot be null');
    }
    if (!data.containsKey('id')) {
      throw StateError('Missing required field: id');
    }
    return pulse;
  });
}

3. Recovery Instructions

Instruction<Cell, Pulse, Pulse> recoveryInstruction() {
  return Instruction((pulse, {cell, user}) {
    final data = pulse.payload as Map<String, dynamic>?;
    if (data == null) return null;
    
    // Recover from invalid data
    final recovered = Map<String, dynamic>.from(data);
    if ((recovered['name']?.toString().trim() ?? '').isEmpty) {
      recovered['name'] = 'Unknown';
    }
    if ((recovered['email']?.toString().trim() ?? '').isEmpty) {
      recovered['email'] = 'unknown@example.com';
    }
    
    return Pulse(recovered);
  });
}

Best Practices

1. Keep Instructions Focused

Each instruction should do one thing and do it well:

// ✅ GOOD - Single responsibility
final validateEmail = validateEmailInstruction();
final validatePhone = validatePhoneInstruction();
final validateAddress = validateAddressInstruction();
final validationPipeline = validateEmail + validatePhone + validateAddress;

// ❌ BAD - Multiple responsibilities
final validateAll = Instruction((pulse, {cell, user}) {
  // Validates email, phone, AND address
  // Too much logic in one instruction
});

2. Use Descriptive Names

// ✅ GOOD
final sanitizeCustomerData = sanitizeCustomerInstruction();
final validateOrderTotal = validateTotalInstruction();

// ❌ BAD
final step1 = step1Instruction();
final step2 = step2Instruction();

3. Document Your Instructions

/// Sanitizes customer data by:
/// - Trimming whitespace from all string fields
/// - Converting email to lowercase
/// - Removing null values from metadata
/// - Returns null if customer ID is missing
Instruction<Cell, Pulse, Pulse> sanitizeCustomerInstruction() {
  return Instruction((pulse, {cell, user}) {
    // ...
  });
}

4. Return New Pulses, Don't Modify Input

// ✅ GOOD
return Pulse(newData);

// ❌ BAD
final data = pulse.payload as Map<String, dynamic>;
data['key'] = 'value';  // Modifies input!
return pulse;

5. Handle Null Payloads Gracefully

Instruction<Cell, Pulse, Pulse> safeTransformer() {
  return Instruction((pulse, {cell, user}) {
    final data = pulse.payload as Map<String, dynamic>?;
    if (data == null) {
      print('Null payload received, filtering');
      return null;  // Filter out null payloads
    }
    // Process data...
    return Pulse(transformed);
  });
}

6. Use Type Safety

// ✅ GOOD - Type-safe
Instruction<Cell, Pulse<String>, Pulse<int>> lengthTransformer() {
  return Instruction<Cell, Pulse<String>, Pulse<int>>(
    (pulse, {cell, user}) {
      final text = pulse.payload;
      return Pulse(text.length);
    },
  );
}

7. Log Transformation Steps (for Debugging)

Instruction<Cell, Pulse, Pulse> loggingInstruction(String name) {
  return Instruction((pulse, {cell, user}) {
    print('[$name] Input: ${pulse.payload}');
    // Process...
    print('[$name] Output: $result');
    return Pulse(result);
  });
}

Complete Example

Here's a complete e-commerce order processing pipeline:

import 'package:cell/cell.dart';

// ─────────────────────────────────────────────────────────────
// Instructions
// ─────────────────────────────────────────────────────────────

/// Sanitize raw order data
Instruction<Cell, Pulse, Pulse> sanitizeOrder() {
  return Instruction((pulse, {cell, user}) {
    final raw = pulse.payload as Map<String, dynamic>?;
    if (raw == null) return null;
    
    final sanitized = <String, dynamic>{}
      ..['id'] = raw['id']?.toString().trim()
      ..['customer'] = raw['customer']?.toString().trim() ?? ''
      ..['email'] = raw['email']?.toString().trim().toLowerCase() ?? ''
      ..['items'] = int.tryParse(raw['items']?.toString() ?? '0') ?? 0
      ..['total'] = double.tryParse(raw['total']?.toString() ?? '0.0') ?? 0.0
      ..['status'] = raw['status']?.toString() ?? 'pending';
    
    if ((sanitized['id'] as String?)?.isEmpty ?? true) {
      return null;  // Invalid ID
    }
    
    return Pulse(sanitized);
  });
}

/// Validate order data
Instruction<Cell, Pulse, Pulse> validateOrder() {
  return Instruction((pulse, {cell, user}) {
    final data = pulse.payload as Map<String, dynamic>?;
    if (data == null) return null;
    
    final errors = <String>[];
    if ((data['customer'] as String?)?.isEmpty ?? true) {
      errors.add('Customer name is required');
    }
    final email = data['email'] as String? ?? '';
    if (!email.contains('@') || !email.contains('.')) {
      errors.add('Invalid email format');
    }
    if ((data['items'] as int? ?? 0) < 0) {
      errors.add('Items cannot be negative');
    }
    if ((data['total'] as double? ?? 0.0) < 0) {
      errors.add('Total cannot be negative');
    }
    
    if (errors.isNotEmpty) {
      print('Validation failed: ${errors.join('; ')}');
      return null;
    }
    
    return pulse;
  });
}

/// Apply discount based on customer tier
Instruction<Cell, Pulse, Pulse> applyDiscount() {
  return Instruction((pulse, {cell, user}) {
    final data = pulse.payload as Map<String, dynamic>?;
    if (data == null) return null;
    
    final email = data['email'] as String? ?? '';
    final total = data['total'] as double? ?? 0.0;
    
    double discount = 0.0;
    if (email == 'vip@example.com') {
      discount = total * 0.10;
      print('Applied 10% VIP discount');
    }
    
    final enriched = Map<String, dynamic>.from(data)
      ..['discount'] = discount
      ..['finalTotal'] = total - discount;
    
    return Pulse(enriched);
  });
}

/// Format order as Order object
Instruction<Cell, Pulse, Pulse> formatOrder() {
  return Instruction((pulse, {cell, user}) {
    final data = pulse.payload as Map<String, dynamic>?;
    if (data == null) return null;
    
    final order = Order(
      id: data['id'] as String? ?? 'UNKNOWN',
      customer: data['customer'] as String? ?? 'Unknown',
      email: data['email'] as String? ?? '',
      items: data['items'] as int? ?? 0,
      total: data['finalTotal'] as double? ?? 0.0,
      discount: data['discount'] as double? ?? 0.0,
      status: 'ready',
      processedAt: DateTime.now(),
    );
    
    return Pulse(order);
  });
}

// ─────────────────────────────────────────────────────────────
// Usage
// ─────────────────────────────────────────────────────────────

void main() {
  // 1. Create pipeline
  final pipeline = sanitizeOrder() + validateOrder() + applyDiscount() + formatOrder();
  
  // 2. Create receptor
  final receptor = Receptor.instruction(pipeline);
  
  // 3. Create cell
  final cell = Cell(
    receptor: receptor,
    context: Context.module('order-processing'),
  );
  
  // 4. Process order
  final rawOrder = {
    'id': 'ORD-123',
    'customer': '  John Doe  ',
    'email': 'vip@example.com',
    'items': '2',
    'total': '99.99',
    'status': 'pending',
  };
  
  final result = cell._nucleus.receptor.call(Pulse(rawOrder));
  
  if (result != null && result.payload is Order) {
    final order = result.payload as Order;
    print('Order processed: ${order.id}');
    print('Final total: \$${order.total}');
    print('Discount: \$${order.discount}');
  }
}

Summary

Concept Description
Instruction Reusable unit of logic that transforms pulses
Pipeline Chain of instructions using + operator
Receptor Container that executes instructions
Cell Reactive node that uses receptors
Pulse Immutable signal carrying data

Key Rules

  1. Always return a new pulse - never modify input
  2. Return null to filter - stops the pipeline
  3. Keep instructions focused - one responsibility per instruction
  4. Test instructions in isolation - unit test each one
  5. Use async for I/O - use Instruction.future() for async operations
  6. Document your instructions - explain what they do

Common Patterns

// Pattern: Filter + Transform + Filter
final pipeline = filterEmpty + transform + filterNegative;

// Pattern: Validate + Enrich + Format
final pipeline = validate + enrich + format;

// Pattern: PreProcess + Core + PostProcess (Receptor)
final receptor = Receptor.pipeline(
  preProcess: sanitize,
  instruction: validate + process,
  postProcess: enrich,
);

// Pattern: Async + Sync
final pipeline = fetchData + validate + format + log;

Classes

Instruction<C extends Cell, I extends Pulse, O extends Pulse> Instructions Pipelines & Receptors
A discrete logic unit that defines how a Receptor reacts to a Pulse.