Deployment pipelines, scheduled tasks, notification system, settings management, site setup, payment configuration, and business workflows.
The Clarity platform is deployed as a Docker Compose stack on Ubuntu 22.04 and 24.04 VMs. The build and release process is orchestrated through Azure DevOps Pipelines, producing Docker images pushed to a private registry and a docker-compose.yml artifact for deployment.
Azure DevOps Build Pipeline publishes the docker-compose.yml as a build artifact. The release pipeline downloads this artifact for deployment.
The compose file is transferred to target VMs via SCP (Secure Copy Protocol). The release pipeline authenticates to the VM using SSH keys configured in Azure DevOps.
Before deployment, the compose file undergoes token replacement to inject environment-specific values. Placeholder tokens in the compose file are replaced with actual values from the release pipeline variables.
# Tokens replaced at deploy time
CONNECTION_STRING: {{CONNECTION_STRING}} # Database connection string
HOST_NAME: {{HOST_NAME}} # Public hostname for the siteSSL certificates are automatically provisioned and renewed via Let's Encrypt. The certificate configuration is embedded in the Docker Compose file, requiring no manual certificate management.
Docker images are stored in the internal registry at registry.hq.clarityinternal.com:443. Images are tagged with the build number for traceability.
The scheduler provides cron-based task execution for recurring background work. Tasks inherit from TaskBase and are registered with the DI container at startup. Each task specifies its schedule using a standard cron expression.
public class MyScheduledTask : TaskBase
{
protected override string? Cron => "* * * * *"; // every minute
public override async Task ExecuteAsync(IPipelineContext context)
{
// Task implementation
}
}// In plugin's RegisterServices method
builder.Services.AddScheduledTask<MyScheduledTask>();For ad-hoc background work outside of scheduled tasks, use the background task queue. This is useful for offloading work from request handlers without blocking the response.
// Enqueue work onto the background task queue
scheduler.AddAsyncBackgroundTask(async ctx =>
{
// Use ctx (IPipelineContext), NOT the outer request context
await ctx.RunPipeline<SomeWorkPipeline>();
});Important: Always use the IPipelineContext passed into the background task callback, not the outer request context. The outer context may be disposed by the time the background task executes.
The notification system provides a queued, template-driven approach to sending email, SMS, and push notifications. Notifications are created in code, queued to the database, and processed in batches by a scheduled task. Templates use Handlebars syntax and are fully data-driven.
Categorizes notifications by type (e.g., OrderConfirmation, PasswordReset). Each topic can have multiple templates.
Delivery channel: Email, SMS, or Push. Each method implements INotificationService.
Handlebars-powered template defining the content for a given topic and method. Supports dynamic data and conditional logic.
A queued notification instance with recipient details, replacement data, and a reference to its template.
Tracks delivery state: Pending, Sent, Failed, Retrying. Enables monitoring and retry logic for failed deliveries.
Full Handlebars support including loops, conditionals, and nested object access for rich template rendering.
Templates are stored in the database and can be modified at runtime without redeployment, enabling business user customization.
Each notification can define multiple templates per topic: customer-facing, back-office, and internal audit copies.
To create a notification, subclass NotificationBase and define a Name property that matches the notification topic. The Replacements dictionary provides the template data, and PopulateReplacementsAsync allows for custom data loading logic.
public class OrderConfirmationNotification : NotificationBase
{
public override string Name => "OrderConfirmation";
public Guid OrderId { get; set; }
public override async Task PopulateReplacementsAsync(
IPipelineContext context,
CancellationToken token)
{
var order = await context.GetById<Order>(OrderId, token);
Replacements["orderNumber"] = order.OrderNumber;
Replacements["customerName"] = order.CustomerName;
Replacements["totalAmount"] = order.Total.ToString("C");
Replacements["order"] = order; // nested object for template access
}
}// Create and queue the notification
var notification = new OrderConfirmationNotification
{
OrderId = order.Id,
To = order.CustomerEmail
};
await notification.QueueAsync(context, token);Calling QueueAsync persists the notification to the database with a Pending status. The ProcessNotificationBatchTask scheduled task picks it up on its next run (every minute) and dispatches it through the appropriate INotificationService implementation.
Notification templates are HTML files stored on disk in the plugin's Templates folder. They use Handlebars syntax for dynamic content. File naming and folder structure follow strict conventions to enable automatic registration at startup.
# Pattern: {Topic}.{Method}.{Section}.html (case-sensitive)
OrderConfirmation.Email.body.html # Email body template
OrderConfirmation.Email.subject.html # Email subject line
OrderConfirmation.Email.from.html # Sender address
OrderConfirmation.Email.to.html # Recipient addressSections available: body, subject, from, to. File names are case-sensitive and must match the notification topic name exactly.
PluginRoot/
Templates/
Backend/ # Back-office templates
Frontend/ # Customer-facing templates
OrderConfirmation.Email.body.html
OrderConfirmation.Email.subject.htmlOn startup, the backend automatically creates database records from the disk-based template files. Existing database records are not overwritten, so runtime modifications to templates in the admin UI persist across deployments.
Simple Replacements
<!-- Access top-level replacement values -->
<p>Hello, {{firstName}}!</p>
<p>Your order {{orderNumber}} has been confirmed.</p>Nested Objects
<!-- Dot notation for nested properties -->
<p>{{contact.street1}}</p>
<p>{{contact.city}}, {{contact.state}} {{contact.zip}}</p>Arrays and Loops
<!-- Iterate over arrays with #each -->
<table>
{{#each order.items}}
<tr>
<td>{{this.productName}}</td>
<td>{{this.quantity}}</td>
<td>{{this.price}}</td>
</tr>
{{/each}}
</table>The settings system provides a strongly-typed configuration layer for plugins. Settings are stored in the database with a key-value pattern and accessed via the ISettings interface. Settings can be exposed to the frontend through pipeline hooks.
public class MyPluginSettings : ISettings
{
// Key: "MyPluginSettings:EnableFeatureX"
public bool EnableFeatureX { get; set; } = true;
// Key: "MyPluginSettings:MaxRetryCount"
public int MaxRetryCount { get; set; } = 3;
// Key: "MyPluginSettings:ApiEndpoint"
public string ApiEndpoint { get; set; } = "";
}Keys follow the ClassName:PropertyName pattern with a colon separator. Properties are hydrated from the database automatically when the settings object is resolved.
// Resolve settings from the pipeline context
var settings = context.GetSettings<MyPluginSettings>();
if (settings.EnableFeatureX)
{
// Feature-gated logic
}Settings that need to be available in the frontend are exposed through the BuildPxConfigPipeline. Hooks return IPxConfigSection objects that are serialized to JSON and sent to the client.
public class MyPxConfigHook : IHook<BuildPxConfigPipeline>
{
public async Task ExecuteAsync(BuildPxConfigPipeline pipeline,
IPipelineContext context, CancellationToken token)
{
var settings = context.GetSettings<MyPluginSettings>();
pipeline.Config.Add(new PxConfigSection("myPlugin")
{
["enableFeatureX"] = settings.EnableFeatureX,
["maxRetryCount"] = settings.MaxRetryCount,
});
}
}Security Warning: Never expose credentials, API keys, or secrets via IPxConfigSection. PxConfig data is sent to the browser and is visible in the page source. Only include values that are safe for public exposure.
The Site Setup Wizard is an admin-facing configuration flow located at /admin/system/sitesetup. It provides a multi-step wizard for initial platform configuration, connector setup, and ongoing system management.
Initial platform configuration. Sets the site name, base URL, and primary admin credentials.
Added by ConnectCore. Each connector contributes its own setup routes and configuration forms.
Plugin-specific settings. Each plugin can contribute settings panels visible in the wizard.
Per-connector documentation tabs. Each connector can provide inline help, setup guides, and API reference links.
The ConnectCore plugin extends the setup wizard with a dedicated "Connectors" step. Each registered connector contributes its own setup routes, documentation, and configuration UI. Settings are persisted via the ConnectorsController.
Each connector in the wizard provides three tabs: Setup Guide (step-by-step instructions), Settings (configuration form), and Documentation (reference material and API docs).
Each connector implements IConnectorSettings to declare its configuration surface. Connectors are discovered through the GetConnectorSetupsPipeline and their settings are stored on a per-site basis.
public class NetSuiteConnectorSettings : IConnectorSettings
{
public string ConnectorId => "netsuite";
public string AccountId { get; set; }
public string ConsumerKey { get; set; }
public string ConsumerSecret { get; set; }
public string TokenId { get; set; }
public string TokenSecret { get; set; }
}The GetConnectorSetupsPipeline scans registered plugins for IConnectorSettings implementations and builds the connector list for the setup wizard.
Connector settings are stored per-site, allowing multi-tenant deployments where each site connects to a different ERP instance with its own credentials.
The admin UI automatically renders configuration forms based on the IConnectorSettings properties. When a connector is selected in the setup wizard, its settings form is displayed and values are saved through the ConnectorsController REST API.
Payment configuration is managed through the PaymentSettings class, which controls the payment mode, enabled providers, dashboard routes, and supported payment methods. The GetPaymentProviderByTypePipeline handles multi-provider selection at runtime.
Controls the active payment environment. Options: Testing (sandbox credentials, mock transactions) or Live (production credentials, real transactions).
List of active payment provider identifiers. Multiple providers can be enabled simultaneously, and the system selects the appropriate one based on payment type and configuration.
Enables the wallet/balance dashboard in the customer portal for viewing account balances and stored payment methods.
Enables the credits dashboard for customers to view and manage store credits, refund credits, and promotional balances.
Enables credit card payments through the configured provider. Supports tokenized card storage for returning customers.
Enables ACH (bank transfer) payments. Requires provider-level ACH support and bank account verification configuration.
Enables webhook notifications when payments are processed, allowing external systems to react to payment events.
Enables granular refund processing at the individual line item level rather than full order refunds only.
The GetPaymentProviderByTypePipeline resolves the correct payment provider at runtime based on the payment type and the site's configuration. This allows different payment methods (credit card, ACH) to route to different providers within the same deployment.
The platform supports end-to-end business workflows built on composable building blocks. The primary flow is the Order-to-Cash cycle that spans from sales collection through payment processing and ERP synchronization.
Processes payments against customer account balances. Applies credits and calculates remaining balances due.
Handles direct invoice payment processing. Supports partial payments, multiple payment methods, and split payments across invoices.
Computes the outstanding balance for a customer or invoice, factoring in payments, credits, adjustments, and pending transactions.
The Site plugin provides the customer-facing dashboard and checkout flows. All flows are composed from the platform's building blocks (pipelines, hooks, and entities), not hardcoded.
Each connector handles ERP-specific differences (field mappings, validation rules, sync timing) through its own pipeline hooks, keeping the core workflow agnostic to the target ERP.
The Clarity repository uses Git submodules for Core and each plugin. Understanding the submodule workflow is essential for day-to-day development and coordinating changes across multiple repositories.
# Initialize and pull all submodules after cloning
git submodule update --init
# Run a command across all submodules
git submodule foreach 'git checkout develop && git pull'
# Check status of all submodules
git submodule foreach 'git status'
# Update submodule references to latest commits
git submodule update --remoteWhen working on a feature that spans multiple submodules, branches must be coordinated to ensure all changes are tested together before merging.
Use the same branch name across all submodules involved in a feature (e.g., feature/add-new-payment-method) to maintain traceability.
The parent repository's branch should reference the feature branch commits in each submodule so CI builds and tests run against the complete changeset.
Open PRs in each submodule repository for the changes specific to that submodule.
In the parent project PR description, include links to all related submodule PRs for cross-reference.
Wait for submodule PRs to be reviewed and merged to develop before merging the parent project PR.
After submodule merges, update the parent project's submodule references to point to the develop branch HEAD.
Best Practice: Always wait for submodule PRs to merge to develop before updating the parent project's submodule references. This ensures the parent always references stable, reviewed commits rather than in-flight feature branch code.
The platform deploys as Docker containers orchestrated by Kubernetes. Deployments use rolling updates, ensuring zero-downtime releases by gradually replacing old pods with new ones. If a deployment fails health checks, Kubernetes automatically rolls back to the previous version. Manual rollback is also available via container image retagging. See Deployment.
The notification system provides multi-channel delivery (email, SMS, in-app) through a pipeline-based architecture. Notifications are created using templates with variable substitution, and delivery is managed by the notification pipeline which supports pre-hooks for custom routing logic and post-hooks for delivery confirmation tracking. See Notification System and Templates.
Production issues are triaged through structured logging and monitoring. The platform uses Kubernetes health checks for automated detection and pod restart. Application-level errors are captured with full stack traces and context. For connector sync failures, retry queues with dead-letter handling ensure no data is lost. Critical payment failures trigger immediate notifications to operations teams. See Failure Modes.
The built-in scheduler manages recurring operations like connector sync cycles, notification delivery retries, data cleanup, and report generation. Schedules are configurable per-site and support cron-like expressions. The scheduler uses distributed locking via Redis to prevent duplicate execution in multi-instance deployments. See Scheduler.
The platform is designed for graceful degradation. Kubernetes automatically restarts failed pods. Payment transactions that fail mid-process are left in a well-defined error state (never partially committed). Connector sync failures are queued for retry. The stateless application design means any healthy instance can serve any request. Monitoring alerts notify teams of persistent failures requiring manual intervention. See Failure Modes.
Today, infrastructure provisioning takes approximately 5 minutes: create a Kubernetes namespace, provision a database, configure secrets, generate deployment YAML, and deploy containers. Business configuration (site setup, connector credentials, payment provider) takes an additional 30-60 minutes with the customer. With multi-tenancy, infrastructure drops to ~30 seconds — just create a database and register the tenant. The self-service vision includes a Tenant Admin Portal where provisioning happens in real-time with pre-loaded configuration templates. See Customer Onboarding.
In single-tenant, a fleet update pipeline pushes new Docker image tags to all customer namespaces, with optional canary deployment (update 5% first, then 50%, then 100%). Each customer's instance restarts and runs any pending migrations. In multi-tenant, a single deployment restart updates all tenants immediately, with feature flags controlling gradual feature enablement. Customers requiring version freezes can be pinned to specific image tags or have new features disabled via flags. See Update Strategy.