Plugin Development Guide
Build Custom Workflow Actions for BarakoCMS
This guide shows you how to create custom workflow action plugins without modifying core code.
Table of Contents
- Introduction
- Quick Start
- Plugin Architecture
- Creating Your First Plugin
- Built-in Plugin Examples
- Advanced Topics
- Testing
- Best Practices
- Troubleshooting
Introduction
What are Workflow Action Plugins?
Workflow action plugins are reusable components that execute specific tasks when workflow conditions are met. They enable you to:
- Extend functionality without touching core code
- Encapsulate business logic into reusable actions
- Leverage dependency injection for services
- Maintain clean separation between workflow logic and actions
Why Use Plugins?
Before (Hardcoded):
// Tightly coupled to WorkflowEngine
if (action.Type == "SendEmail")
{
// Hardcoded email logic
_emailService.Send(...);
}After (Plugin-Based):
// Clean plugin interface
public class EmailAction : IWorkflowAction
{
public async Task ExecuteAsync(...) { }
}Benefits:
- ✅ Add new actions by creating a class
- ✅ No core code modifications
- ✅ Testable in isolation
- ✅ Auto-discovered by DI
Quick Start
3-Minute Plugin
Create a simple notification plugin:
// 1. Add metadata attribute
[WorkflowActionMetadata(
Description = "Log a message to console",
RequiredParameters = new[] { "message" },
ExampleJson = @"{""type"": ""LogMessage"", ""parameters"": {""message"": ""Hello World""}}"
)]
// 2. Implement IWorkflowAction
public class LogMessageAction : IWorkflowAction
{
private readonly ILogger<LogMessageAction> _logger;
public string Type => "LogMessage";
public LogMessageAction(ILogger<LogMessageAction> logger)
{
_logger = logger;
}
// 3. Implement ExecuteAsync
public async Task ExecuteAsync(
Dictionary<string, string> parameters,
Content triggeringContent,
CancellationToken cancellationToken = default)
{
var message = parameters["message"];
_logger.LogInformation("Workflow: {Message}", message);
await Task.CompletedTask;
}
}That's it! The plugin is auto-discovered and ready to use.
Use in Workflow
{
"name": "Log on Publish",
"triggerContentType": "Article",
"triggerEvent": "Published",
"actions": [{
"type": "LogMessage",
"parameters": {
"message": "Article {{data.Title}} was published!"
}
}]
}Plugin Architecture
The IWorkflowAction Interface
public interface IWorkflowAction
{
/// <summary>
/// Unique identifier for this action type
/// </summary>
string Type { get; }
/// <summary>
/// Execute the workflow action
/// </summary>
Task ExecuteAsync(
Dictionary<string, string> parameters, // From workflow definition
Content triggeringContent, // Content that triggered workflow
CancellationToken cancellationToken = default);
}Metadata Attribute
[AttributeUsage(AttributeTargets.Class)]
public class WorkflowActionMetadataAttribute : Attribute
{
public string Description { get; set; }
public string[] RequiredParameters { get; set; }
public string ExampleJson { get; set; }
}Auto-Discovery
Plugins are automatically discovered via Dependency Injection:
// ServiceCollectionExtensions.cs
services.AddScoped<IWorkflowAction, EmailAction>();
services.AddScoped<IWorkflowAction, SmsAction>();
services.AddScoped<IWorkflowAction, YourCustomAction>(); // Auto-discovered!Creating Your First Plugin
Step 1: Create the Class
# Create file
touch barakoCMS/Features/Workflows/Actions/SlackNotificationAction.csStep 2: Implement Interface
using barakoCMS.Features.Workflows;
using barakoCMS.Infrastructure.Attributes;
using barakoCMS.Models;
namespace barakoCMS.Features.Workflows.Actions;
[WorkflowActionMetadata(
Description = "Send notification to Slack channel",
RequiredParameters = new[] { "webhookUrl", "message" },
ExampleJson = @"{
""type"": ""SlackNotification"",
""parameters"": {
""webhookUrl"": ""https://hooks.slack.com/services/....."",
""message"": ""New content: {{data.Title}}""
}
}"
)]
public class SlackNotificationAction : IWorkflowAction
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<SlackNotificationAction> _logger;
public string Type => "SlackNotification";
public SlackNotificationAction(
IHttpClientFactory httpClientFactory,
ILogger<SlackNotificationAction> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task ExecuteAsync(
Dictionary<string, string> parameters,
Content triggeringContent,
CancellationToken cancellationToken = default)
{
var webhookUrl = parameters["webhookUrl"];
var message = parameters["message"];
var payload = new { text = message };
using var client = _httpClientFactory.CreateClient();
var response = await client.PostAsJsonAsync(
webhookUrl, payload, cancellationToken);
if (response.IsSuccessStatusCode)
{
_logger.LogInformation(
"Slack notification sent for content {ContentId}",
triggeringContent.Id);
}
else
{
_logger.LogError(
"Failed to send Slack notification: {StatusCode}",
response.StatusCode);
}
}
}Step 3: Register in DI
// Extensions/ServiceCollectionExtensions.cs
services.AddScoped<IWorkflowAction, SlackNotificationAction>();Step 4: Test with Dry-Run
POST /api/workflows/dry-run
{
"workflow": {
"name": "Test Slack",
"triggerContentType": "Article",
"triggerEvent": "Published",
"actions": [{
"type": "SlackNotification",
"parameters": {
"webhookUrl": "https://hooks.slack.com/...",
"message": "New article published!"
}
}]
},
"sampleContent": {
"id": "...",
"contentType": "Article",
"data": { "Title": "Hello World" }
}
}Built-in Plugin Examples
1. EmailAction (Simple)
[WorkflowActionMetadata(
Description = "Send email notification",
RequiredParameters = new[] { "to", "subject", "body" },
ExampleJson = "..."
)]
public class EmailAction : IWorkflowAction
{
private readonly IEmailService _emailService;
public string Type => "Email";
public async Task ExecuteAsync(
Dictionary<string, string> parameters,
Content content,
CancellationToken ct = default)
{
await _emailService.SendAsync(
parameters["to"],
parameters["subject"],
parameters["body"],
ct);
}
}Usage:
{
"type": "Email",
"parameters": {
"to": "{{data.Email}}",
"subject": "Welcome {{data.FirstName}}!",
"body": "Thank you for signing up."
}
}2. ConditionalAction (Advanced)
[WorkflowActionMetadata(
Description = "Execute actions based on condition",
RequiredParameters = new[] { "condition", "ifTrue", "ifFalse" },
ExampleJson = "..."
)]
public class ConditionalAction : IWorkflowAction
{
private readonly IEnumerable<IWorkflowAction> _actions;
public string Type => "Conditional";
public async Task ExecuteAsync(
Dictionary<string, string> parameters,
Content content,
CancellationToken ct = default)
{
var condition = parameters["condition"];
var result = EvaluateCondition(condition, content);
var actionType = result ? parameters["ifTrue"] : parameters["ifFalse"];
var action = _actions.FirstOrDefault(a => a.Type == actionType);
if (action != null)
{
await action.ExecuteAsync(parameters, content, ct);
}
}
}Usage:
{
"type": "Conditional",
"parameters": {
"condition": "{{data.Amount}} > 1000",
"ifTrue": "Email",
"ifFalse": "SMS"
}
}All 6 Built-in Actions
| Action | Purpose | Required Parameters |
|---|---|---|
| Send email | to, subject, body | |
| SMS | Send SMS | to, message |
| Webhook | HTTP POST | url, payload |
| CreateTask | Create task | title, assignee, dueDate |
| UpdateField | Update content | fieldName, fieldValue |
| Conditional | If/then/else logic | condition, ifTrue, ifFalse |
Advanced Topics
Template Variables
Use dynamic values from content:
// Available system variables
{{id}} // Content ID
{{contentType}} // Content Type
{{status}} // Status (0=Draft, 1=Published, 2=Archived)
{{createdAt}} // Creation timestamp
{{updatedAt}} // Last update timestamp
// Data field variables
{{data.FieldName}} // Any field from content.Data
{{data.Email}}
{{data.Amount}}
{{data.Title}}Resolution happens automatically via ITemplateVariableExtractor:
// In your action
var resolvedMessage = _variableExtractor.ResolveVariables(
parameters["message"],
triggeringContent);Dependency Injection
Inject any registered service:
public class MyAction : IWorkflowAction
{
private readonly IDocumentSession _session; // Database
private readonly IHttpClientFactory _httpFactory; // HTTP
private readonly IMemoryCache _cache; // Cache
private readonly ILogger<MyAction> _logger; // Logging
public MyAction(
IDocumentSession session,
IHttpClientFactory httpFactory,
IMemoryCache cache,
ILogger<MyAction> logger)
{
_session = session;
_httpFactory = httpFactory;
_cache = cache;
_logger = logger;
}
}Accessing Database
public async Task ExecuteAsync(
Dictionary<string, string> parameters,
Content content,
CancellationToken ct)
{
// Query related content
var relatedContent = await _session
.Query<Content>()
.Where(c => c.ContentType == "RelatedType")
.ToListAsync(ct);
// Update content
content.Data["ProcessedAt"] = DateTime.UtcNow.ToString("o");
_session.Update(content);
await _session.SaveChangesAsync(ct);
}Error Handling
public async Task ExecuteAsync(
Dictionary<string, string> parameters,
Content content,
CancellationToken ct)
{
try
{
// Your action logic
await DoWorkAsync(parameters, ct);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex,
"Network error in workflow action {ActionType} for content {ContentId}",
Type, content.Id);
// Don't rethrow - let workflow continue
}
catch (Exception ex)
{
_logger.LogError(ex,
"Unexpected error in workflow action {ActionType}",
Type);
throw; // Rethrow critical errors
}
}Cancellation Tokens
Always respect cancellation tokens:
public async Task ExecuteAsync(
Dictionary<string, string> parameters,
Content content,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested(); // Check at start
await LongRunningOperationAsync(ct); // Pass to async calls
ct.ThrowIfCancellationRequested(); // Check periodically
}Testing
Unit Test Example
public class SlackNotificationActionTests
{
[Fact]
public async Task ExecuteAsync_ShouldPostToWebhook()
{
// Arrange
var mockFactory = new Mock<IHttpClientFactory>();
var mockLogger = new Mock<ILogger<SlackNotificationAction>>();
var action = new SlackNotificationAction(
mockFactory.Object,
mockLogger.Object);
var parameters = new Dictionary<string, string>
{
["webhookUrl"] = "https://hooks.slack.com/test",
["message"] = "Test message"
};
var content = new Content { Id = Guid.NewGuid() };
// Act
await action.ExecuteAsync(parameters, content);
// Assert
// Verify HTTP POST was made
mockFactory.Verify(f => f.CreateClient(It.IsAny<string>()), Times.Once);
}
}Integration Test with Dry-Run
[Fact]
public async Task DryRun_SlackAction_ShouldSimulate()
{
// Arrange
var client = _factory.CreateClient();
var request = new
{
workflow = new
{
name = "Test Slack",
triggerContentType = "Article",
triggerEvent = "Published",
actions = new[]
{
new
{
type = "SlackNotification",
parameters = new
{
webhookUrl = "https://hooks.slack.com/...",
message = "Test: {{data.Title}}"
}
}
}
},
sampleContent = new
{
contentType = "Article",
data = new { Title = "Test Article" }
}
};
// Act
var response = await client.PostAsJsonAsync("/api/workflows/dry-run", request);
// Assert
response.IsSuccessStatusCode.Should().BeTrue();
var result = await response.Content.ReadFromJsonAsync<DryRunResult>();
result.Success.Should().BeTrue();
}Best Practices
✅ DO
Keep plugins stateless
csharp// Good: Stateless public class EmailAction : IWorkflowAction { private readonly IEmailService _emailService; // Injected dependency only }Use async/await properly
csharp// Good: Truly async public async Task ExecuteAsync(...) { await _httpClient.PostAsync(...); }Validate parameters early
csharppublic async Task ExecuteAsync( Dictionary<string, string> parameters, ...) { if (!parameters.ContainsKey("required")) throw new ArgumentException("Missing required parameter"); }Log execution details
csharp_logger.LogInformation( "Executing {ActionType} for content {ContentId} with params {Params}", Type, content.Id, JsonSerializer.Serialize(parameters));
❌ DON'T
Don't store state in fields
csharp// Bad: Stateful (breaks in concurrent scenarios) public class BadAction : IWorkflowAction { private string _lastMessage; // Don't do this! }Don't block async code
csharp// Bad: Blocking public async Task ExecuteAsync(...) { await Task.Run(() => SyncMethod()).Wait(); // Don't! }Don't ignore cancellation tokens
csharp// Bad: Ignores cancellation public async Task ExecuteAsync(..., CancellationToken ct) { await LongOperation(); // Should pass ct! }
Troubleshooting
Plugin Not Discovered
Problem: Your plugin doesn't appear in GET /api/workflows/actions
Solutions:
- Verify it implements
IWorkflowAction - Check it's registered in DI:csharp
services.AddScoped<IWorkflowAction, YourAction>(); - Ensure metadata attribute is present
- Rebuild and restart application
"Unknown action type" Error
Problem: Validation fails with unknown action type
Solutions:
- Check
Typeproperty matches workflow JSON - Verify plugin is registered in DI
- Use
GET /api/workflows/actionsto see available types
Template Variables Not Resolving
Problem: \{\{data.Field\}\} shows literally in output
Solutions:
- Inject
ITemplateVariableExtractor - Call
ResolveVariables()before using parameter values - Check field exists in content.Data
Parameters Missing
Problem: KeyNotFoundException when accessing parameters
Solutions:
- Check
RequiredParametersin metadata attribute - Validate parameters exist before accessing:csharp
if (!parameters.TryGetValue("key", out var value)) throw new ArgumentException("Missing parameter: key");
Action Not Executing
Problem: Workflow created but action never runs
Solutions:
- Check async daemon is running (visible in logs)
- Verify workflow conditions are met
- Check event is being published
- Review execution logs:
GET /api/workflows/{id}/debug
Next Steps
- ✅ Follow Migration Guide to upgrade existing workflows
- ✅ See
AttendancePOCproject for real-world examples - ✅ Join community discussions on GitHub
- ✅ Share your custom plugins!
Happy Plugin Building! 🚀