Exam Area: Content Area 4 – Framework Components (15%)
Source: https://academy.optimizely.com/student/path/3128969/activity/4970328 | /4970355
Published: Feb 17, 2026
- Mechanisms: Background processing and content lifecycle hooks.
- Platform: Built on .NET Dependency Injection in CMS 12.
- Architecture: Master scheduled jobs and
IContentEventsfor scalability.- Constructor Injection is fully supported in CMS 12 Scheduled Jobs.
Scheduled Jobs are tasks that run automatically on a schedule within the CMS. They run outside the standard web request in an anonymous context. Common uses include:
The class must inherit from EPiServer.Scheduler.ScheduledJobBase and be decorated with the [ScheduledPlugIn] attribute. This attribute registers the job in the CMS database and makes it manageable via the Admin UI.
[ScheduledPlugIn(
DisplayName = "Archive Expired Content",
GUID = "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d")]
public class ContentArchivingJob : ScheduledJobBase
{
private bool _stopSignaled;
private readonly IContentRepository _repository;
// CMS 12 supports full constructor injection in scheduled jobs
public ContentArchivingJob(IContentRepository repository)
{
_repository = repository;
IsStoppable = true;
}
public override string Execute()
{
OnStatusChanged("Starting content archival...");
// Logical processing loop
foreach (var item in GetItemsToArchive())
{
if (_stopSignaled) return "Job stopped manually by administrator.";
// Archive logic here...
}
return "Archival completed successfully.";
}
// Override Stop() for graceful termination — required with large datasets
public override void Stop()
{
_stopSignaled = true;
}
}
Key methods:
Execute() – Entry point; returns a string displayed in the "Status" column of job historyStop() – Override to respect admin requests to halt; sets _stopSignaled = trueOnStatusChanged("...") – Updates progress in the Admin UI[ScheduledPlugIn(
DisplayName = "My Job", // Display name in Admin
Description = "Job description", // Description
GUID = "unique-guid-here", // Unique GUID — do NOT change after deploy
DefaultIntervalType = ScheduledIntervalType.Hours,
DefaultInterval = 24, // Run every 24 hours
Restartable = true, // Resume if app restarts (requires idempotent logic)
SortIndex = 100 // Order in the Admin list
)]
ScheduledIntervalType.Minutes | Hours | Days | Weeks | Months | Never
[ScheduledPlugIn(
DisplayName = "Content Cleanup Job",
GUID = "AAAABBBB-1234-5678-CCCC-000000000001")]
public class ContentCleanupJob : ScheduledJobBase
{
private readonly IContentRepository _contentRepository;
private readonly ILogger<ContentCleanupJob> _logger;
// Constructor Injection (CMS 12 — fully supported)
public ContentCleanupJob(
IContentRepository contentRepository,
ILogger<ContentCleanupJob> logger)
{
_contentRepository = contentRepository;
_logger = logger;
IsStoppable = true;
}
public override string Execute()
{
_logger.LogInformation("Content cleanup job started");
var deletedCount = CleanupExpiredContent();
return $"Cleaned up {deletedCount} expired content items.";
}
}
Jobs run under an anonymous context by default. If an operation requires a specific role (e.g., "WebAdmins"), the developer must use IPrincipalAccessor to assign a security principal inside Execute().
public class ContentSyncJob : ScheduledJobBase
{
private readonly IPrincipalAccessor _principalAccessor;
private readonly IContentRepository _contentRepository;
public ContentSyncJob(IPrincipalAccessor principalAccessor, IContentRepository contentRepository)
{
_principalAccessor = principalAccessor;
_contentRepository = contentRepository;
IsStoppable = true;
}
public override string Execute()
{
var originalPrincipal = _principalAccessor.Principal;
try
{
// Elevate permissions for this job
_principalAccessor.Principal = new GenericPrincipal(
new GenericIdentity("ScheduledJobUser"),
new[] { "WebAdmins", "WebEditors" }
);
// Do work that requires elevated permissions
// ...
}
finally
{
_principalAccessor.Principal = originalPrincipal;
}
return "Sync completed.";
}
}
Multi-instance environments: The Optimizely scheduler ensures only one instance executes a given job at a time. Logic must be idempotent to handle server restarts.
Content events allow you to hook into state changes of content items (pages, blocks, media) — the primary method for content governance and triggering external integrations.
[InitializableModule]
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class EventInitialization : IInitializableModule
{
public void Initialize(InitializationEngine context)
{
var events = context.Locate.Advanced.GetInstance<IContentEvents>();
events.SavingContent += OnSavingContent;
events.PublishedContent += OnPublishedContent;
}
public void Uninitialize(InitializationEngine context)
{
var events = context.Locate.Advanced.GetInstance<IContentEvents>();
events.SavingContent -= OnSavingContent; // ⚠️ IMPORTANT: Must unsubscribe to avoid memory leaks
events.PublishedContent -= OnPublishedContent;
}
private void OnSavingContent(object sender, ContentEventArgs e)
{
// Enforce business rules
if (e.Content is ArticlePage article && string.IsNullOrEmpty(article.MetaDescription))
{
e.CancelAction = true;
e.CancelReason = "Meta Description is required for SEO compliance.";
}
}
private void OnPublishedContent(object sender, ContentEventArgs e)
{
// Trigger external integrations (e.g. Optimizely Graph sync)
// Use async/background queue to avoid blocking the request thread
}
}
| Event | Timing | Use Case |
|---|---|---|
| SavingContent | Pre-persistence | Data validation and blocking illegal saves |
| SavedContent | Post-persistence | Triggering local cache invalidations |
| PublishingContent | Pre-approval | Final business logic checks before going live |
| PublishedContent | Post-live | External API triggers (e.g. Optimizely Graph sync) |
PublishedContent thread; enqueue high-latency callsSaveAction.SkipValidation to prevent infinite loops when modifying content inside its own event handlerpublic class JobManagementService
{
private readonly IScheduledJobRepository _jobRepository;
private readonly IScheduledJobExecutor _jobExecutor;
public JobManagementService(IScheduledJobRepository jobRepository, IScheduledJobExecutor jobExecutor)
{
_jobRepository = jobRepository;
_jobExecutor = jobExecutor;
}
public async Task RunJobAsync(Guid jobGuid)
{
var job = _jobRepository.Get(jobGuid);
await _jobExecutor.StartAsync(job, new JobExecutionOptions
{
Trigger = ScheduledJobTrigger.User
});
}
public void EnableJob(Guid jobId)
{
var job = _jobRepository.Get(jobId);
job.IsEnabled = true;
_jobRepository.Save(job);
}
}
[ScheduledPlugIn])ScheduledJobBase)Execute() — returns a string displayed in the Status column)IsStoppable = true, override Stop(), check _stopSignaled)IPrincipalAccessor if elevated permissions are needed)Uninitialize()? (To avoid memory leaks from dangling delegates)SavingContent — set e.CancelAction = true to block)Restartable = true in [ScheduledPlugIn] mean? (The job can resume if the app restarts — requires idempotent logic)