Advanced Controller Patterns
Advanced Controller Patterns
This section covers advanced patterns, best practices, and real-world scenarios for building robust, maintainable, and high-performance controllers in Magentrix.
Table of Contents
- Design Patterns
- Error Handling Strategies
- Performance Optimization
- Testing Controllers
- Common Pitfalls & Solutions
- Real-World Scenarios
- Migration & Maintenance
Design Patterns
Repository Pattern
Separate data access logic from business logic for better testability and maintainability.
Without Repository Pattern:
public class ContactController : AspxController
{
public override ActionResponse Index()
{
// Data access mixed with controller logic
var contacts = Database.Query<Contact>()
.Where(c => c.IsActive)
.OrderBy(c => c.LastName)
.ToList();
return View(contacts);
}
}
With Repository Pattern:
// Repository class
public class ContactRepository
{
public List<Contact> GetActiveContacts()
{
return Database.Query<Contact>()
.Where(c => c.IsActive)
.OrderBy(c => c.LastName)
.ToList();
}
public Contact GetById(string id)
{
return Database.Retrieve<Contact>(id);
}
public List<Contact> SearchByName(string searchTerm)
{
return Database.Query<Contact>()
.Where(c => c.FirstName.Contains(searchTerm) ||
c.LastName.Contains(searchTerm))
.OrderBy(c => c.LastName)
.ToList();
}
public void Create(Contact contact)
{
Database.Insert(contact);
}
public void Update(Contact contact)
{
Database.Update(contact);
}
public void Delete(string id)
{
Database.Delete(id);
}
}
// Controller using repository
public class ContactController : AspxController
{
private ContactRepository _repository = new ContactRepository();
public override ActionResponse Index()
{
var contacts = _repository.GetActiveContacts();
return View(contacts);
}
public ActionResponse Search(string query)
{
var results = _repository.SearchByName(query);
return View("Index", results);
}
}
Benefits:
- Cleaner controller code
- Reusable data access logic
- Easier to test
- Centralized query logic
Service Layer Pattern
Encapsulate business logic in service classes.
// Service class
public class OpportunityService
{
public bool CanApprove(Opportunity opp, UserInfo user)
{
// Business rule: Closed opportunities can't be approved
if (opp.IsClosed)
return false;
// Business rule: Only managers can approve
if (!user.Roles.Contains("Sales Manager"))
return false;
// Business rule: High-value opportunities need director approval
if (opp.Amount > 100000 && !user.Roles.Contains("Sales Director"))
return false;
return true;
}
public void Approve(Opportunity opp, UserInfo user)
{
if (!CanApprove(opp, user))
throw new UnauthorizedAccessException("User cannot approve this opportunity.");
opp.Status = "Approved";
opp.ApprovedBy = user.Name;
opp.ApprovedDate = DateTime.Now;
Database.Update(opp);
// Send notification email
SendApprovalNotification(opp);
}
private void SendApprovalNotification(Opportunity opp)
{
// Email logic
}
}
// Controller using service
public class OpportunityController : AspxController
{
private OpportunityService _service = new OpportunityService();
[HttpPost]
[AuthorizeAction(Entity = "Opportunity", Action = StandardAction.Edit)]
public ActionResponse Approve(string id)
{
try
{
var opp = Database.Retrieve<Opportunity>(id);
if (!_service.CanApprove(opp, UserInfo))
{
AspxPage.AddError("You do not have permission to approve this opportunity.");
return RedirectToAction("Details", new { id = id });
}
_service.Approve(opp, UserInfo);
AspxPage.AddMessage("Opportunity approved successfully.");
return RedirectToAction("Details", new { id = id });
}
catch (Exception ex)
{
SystemInfo.Error(ex);
return RedirectToError("An error occurred during approval.");
}
}
}
Factory Pattern
Create objects dynamically based on conditions.
// Factory for creating different report generators
public class ReportGeneratorFactory
{
public static IReportGenerator Create(string reportType)
{
switch (reportType.ToLower())
{
case "csv":
return new CsvReportGenerator();
case "pdf":
return new PdfReportGenerator();
case "excel":
return new ExcelReportGenerator();
default:
throw new ArgumentException($"Unknown report type: {reportType}");
}
}
}
public interface IReportGenerator
{
byte[] Generate(List<Account> data);
string ContentType { get; }
string FileExtension { get; }
}
public class CsvReportGenerator : IReportGenerator
{
public string ContentType => "text/csv";
public string FileExtension => ".csv";
public byte[] Generate(List<Account> data)
{
var csv = new StringBuilder();
csv.AppendLine("Name,Type,Industry,Revenue");
foreach (var account in data)
csv.AppendLine($"{account.Name},{account.Type},{account.Industry},{account.AnnualRevenue}");
return Encoding.UTF8.GetBytes(csv.ToString());
}
}
public class PdfReportGenerator : IReportGenerator
{
public string ContentType => "application/pdf";
public string FileExtension => ".pdf";
public byte[] Generate(List<Account> data)
{
// PDF generation logic
return PdfHelper.ConvertHtmlToPdf(GenerateHtml(data));
}
private string GenerateHtml(List<Account> data)
{
// HTML template logic
return "...";
}
}
// Controller using factory
public class ReportsController : AspxController
{
public ActionResponse Export(string format)
{
try
{
var accounts = Database.Query<Account>().Limit().ToList();
var generator = ReportGeneratorFactory.Create(format);
var fileBytes = generator.Generate(accounts);
var filename = $"accounts_report_{DateTime.Now:yyyyMMdd}{generator.FileExtension}";
return File(fileBytes, generator.ContentType, filename);
}
catch (ArgumentException ex)
{
AspxPage.AddError(ex.Message);
return RedirectToAction("Index");
}
}
}
Error Handling Strategies
Centralized Error Handling
Create a base controller with common error handling.
public class SecureController : AspxController
{
protected ActionResponse HandleError(Exception ex, string userMessage = null)
{
// Log the error
SystemInfo.Debug($"Error: {ex.Message}");
SystemInfo.Error(ex);
// Return user-friendly error
var message = userMessage ?? "An error occurred. Please try again or contact support.";
return RedirectToError(message);
}
}
// Usage
public class ContactController : SecureController
{
public ActionResponse Details(string id)
{
try
{
var contact = Database.Retrieve<Contact>(id);
if (contact == null)
return PageNotFound();
return View(contact);
}
catch (Exception ex)
{
return HandleError(ex, "Unable to load contact details.");
}
}
}
Graceful Degradation
Handle failures gracefully without breaking the entire page.
public override ActionResponse Index()
{
var viewModel = new DashboardViewModel();
// Try to load statistics
try
{
viewModel.TotalAccounts = Database.Query<Account>().Count();
viewModel.TotalContacts = Database.Query<Contact>().Count();
}
catch (Exception ex)
{
EventLogProvider.Curre($"Stats error: {ex.Message}");
viewModel.StatsError = "Unable to load statistics at this time.";
}
// Try to load recent activities
try
{
viewModel.RecentActivities = Database.Query<Activity>()
.OrderByDescending(a => a.CreatedDate)
.Take(10)
.ToList();
}
catch (Exception ex)
{
SystemInfo.Error(ex);
viewModel.ActivitiesError = "Unable to load recent activities.";
}
// Always return view, even with partial data
return View(viewModel);
}
Database Query Optimization
❌ Bad - N+1 Query Problem:
public override ActionResponse Index()
{
var opportunities = Database.Query<Opportunity>().ToList();
foreach (var opp in opportunities)
{
// This executes a separate query for EACH opportunity!
opp.Account = Database.Retrieve<Account>(opp.AccountId);
}
return View(opportunities);
}
✅ Good - Single Query with Related Data:
public override ActionResponse Index()
{
// Retrieve opportunities
var opportunities = Database.Query<Opportunity>().ToList();
// Get all unique account IDs
var accountIds = opportunities.Select(o => o.AccountId).Distinct().ToList();
// Retrieve all accounts in one query
var accounts = Database.Query<Account>()
.Where(a => accountIds.Contains(a.Id))
.ToList();
// Create lookup dictionary
var accountLookup = accounts.ToDictionary(a => a.Id);
// Assign accounts to opportunities
foreach (var opp in opportunities)
{
if (accountLookup.ContainsKey(opp.AccountId))
opp.Account = accountLookup[opp.AccountId];
}
return View(opportunities);
}
Pagination
Without Pagination (Loads ALL records):
public override ActionResponse Index()
{
// ❌ Loads thousands of records!
var contacts = Database.Query<Contact>().ToList();
return View(contacts);
}
With Pagination:
public override ActionResponse Index(int page = 1, int pageSize = 25)
{
int skip = (page - 1) * pageSize;
// Load only one page of records
var contacts = Database.Query<Contact>()
.OrderBy(c => c.LastName)
.Skip(skip)
.Take(pageSize)
.ToList();
// Get total count for pagination controls
int totalCount = Database.Query<Contact>().Count();
int totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
// Pass pagination info to view
DataBag.CurrentPage = page;
DataBag.PageSize = pageSize;
DataBag.TotalPages = totalPages;
DataBag.TotalRecords = totalCount;
return View(contacts);
}
Lazy Loading
Load data only when needed.
public class LazyDashboardController : AspxController
{
// Initial page load - minimal data
public override ActionResponse Index()
{
return View();
}
// AJAX endpoint - load statistics on demand
[HandleExceptionsForJson]
public ActionResponse GetStatistics()
{
var stats = new
{
TotalAccounts = Database.Query<Account>().Count(),
TotalContacts = Database.Query<Contact>().Count(),
OpenOpportunities = Database.Query<Opportunity>()
.Where(o => !o.IsClosed)
.Count()
};
return Json(stats, JsonRequestBehavior.AllowGet);
}
// AJAX endpoint - load activities on demand
[HandleExceptionsForJson]
public ActionResponse GetRecentActivities(int count = 10)
{
var activities = Database.Query<Activity>()
.OrderByDescending(a => a.CreatedDate)
.Take(count)
.ToList();
return Json(activities, JsonRequestBehavior.AllowGet);
}
}
JavaScript on Active Page:
// Load statistics when page loads
fetch('/acls/LazyDashboard/GetStatistics')
.then(response => response.json())
.then(stats => {
document.getElementById('totalAccounts').textContent = stats.TotalAccounts;
document.getElementById('totalContacts').textContent = stats.TotalContacts;
});
// Load activities when user clicks tab
document.getElementById('activitiesTab').addEventListener('click', function() {
fetch('/acls/LazyDashboard/GetRecentActivities?count=20')
.then(response => response.json())
.then(activities => {
renderActivities(activities);
});
});
Testing Controllers
Manual Testing Checklist
For each controller action, test:
✅ Authenticated user with correct permissions
✅ Authenticated user without permissions
✅ Guest user (not logged in)
✅ Different security roles
✅ Portal users from different accounts
✅ Valid input data
✅ Invalid input data
✅ Missing required parameters
✅ NULL or empty values
✅ SQL injection attempts
✅ Cross-site scripting (XSS) attempts
✅ CSRF attack simulation
✅ Large datasets (performance)
✅ Concurrent requests
✅ Edge cases and boundary conditions
Test Data Setup
Create helper methods for test data.
public class TestDataHelper
{
public static Contact CreateTestContact(string firstName = "Test", string lastName = "User")
{
var contact = new Contact
{
FirstName = firstName,
LastName = lastName,
Email = $"{firstName.ToLower()}.{lastName.ToLower()}@test.com",
Phone = "(555) 123-4567"
};
Database.Insert(contact);
return contact;
}
public static Account CreateTestAccount(string name = "Test Account")
{
var account = new Account
{
Name = name,
Type = "Customer",
Industry = "Technology"
};
Database.Insert(account);
return account;
}
public static void CleanupTestData(string contactId = null, string accountId = null)
{
if (contactId != null)
Database.Delete<Contact>(contactId);
if (accountId != null)
Database.Delete<Account>(accountId);
}
}
Debugging Techniques
1. Add Detailed Logging:
public ActionResponse ComplexOperation(string id)
{
SystemInfo.Debug($"[ComplexOperation] Starting for ID: {id}");
SystemInfo.Debug($"[ComplexOperation] User: {UserInfo.Name}, IsPortalUser: {UserInfo.IsPortalUser}");
try
{
var record = Database.Retrieve<Contact>(id);
SystemInfo.Debug($"[ComplexOperation] Retrieved record: {record.FirstName} {record.LastName}");
// Process record
ProcessRecord(record);
SystemInfo.Debug($"[ComplexOperation] Processing complete");
return View(record);
}
catch (Exception ex)
{
SystemInfo.Error(ex);
throw;
}
}
2. Validate Assumptions:
public ActionResponse ProcessData(string accountId)
{
// Validate assumption: accountId is not null
if (string.IsNullOrEmpty(accountId))
{
SystemInfo.Debug("ERROR: accountId is null or empty");
return RedirectToError("Invalid account ID.");
}
var account = Database.Retrieve<Account>(accountId);
// Validate assumption: account exists
if (account == null)
{
SystemInfo.Debug($"ERROR: Account not found: {accountId}");
return PageNotFound();
}
// Validate assumption: user has access
if (UserInfo.IsPortalUser && account.Id != UserInfo.Account.Id)
{
SystemInfo.Debug($"ERROR: User {UserInfo.Name} attempted to access account {accountId}");
return UnauthorizedAccessResponse();
}
return View(account);
}
Common Pitfalls & Solutions
Pitfall 1: Not Checking for Null
❌ Problem:
public ActionResponse Details(string id)
{
var contact = Database.Retrieve<Contact>(id);
return View(contact); // Crashes if contact is null
}
✅ Solution:
public ActionResponse Details(string id)
{
var contact = Database.Retrieve<Contact>(id);
if (contact == null)
return PageNotFound();
return View(contact);
}
Pitfall 2: Returning View After POST
❌ Problem:
[HttpPost]
public ActionResponse Create(Contact model)
{
Database.Insert(model);
AspxPage.AddMessage("Contact created!");
// Browser refresh will resubmit form!
return View(model);
}
✅ Solution (PRG Pattern):
[HttpPost]
public ActionResponse Create(Contact model)
{
Database.Insert(model);
AspxPage.AddMessage("Contact created!");
// Redirect after POST
return RedirectToAction("Details", new { id = model.Id });
}
Pitfall 3: Loading Too Much Data
❌ Problem:
public override ActionResponse Index()
{
// Loads 10,000+ records!
var contacts = Database.Query<Contact>().Limit(50).ToList();
return View(contacts);
}
✅ Solution:
public override ActionResponse Index(int page = 1)
{
int pageSize = 25;
var contacts = Database.Query<Contact>()
.OrderBy(c => c.LastName)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
return View(contacts);
}
Pitfall 4: Not Validating User Input
❌ Problem:
public ActionResponse Transfer(string id, string newOwnerId)
{
var opp = Database.Retrieve<Opportunity>(id);
opp.OwnerId = newOwnerId; // Trusting user input!
Database.Update(opp);
return RedirectToAction("Index");
}
✅ Solution:
public ActionResponse Transfer(string id, string newOwnerId)
{
// Validate opportunity exists
var opp = Database.Retrieve<Opportunity>(id);
if (opp == null)
return PageNotFound();
// Validate new owner exists and is active
var newOwner = Database.Retrieve<User>(newOwnerId);
if (newOwner == null || !newOwner.IsActive)
{
AspxPage.AddError("Invalid owner specified.");
return RedirectToAction("Details", new { id = id });
}
// Validate user has permission
if (UserInfo.IsPortalUser)
{
AspxPage.AddError("Portal users cannot transfer opportunities.");
return RedirectToAction("Details", new { id = id });
}
opp.OwnerId = newOwnerId;
Database.Update(opp);
return RedirectToAction("Index");
}
Pitfall 5: Exposing Sensitive Information in Errors
❌ Problem:
catch (Exception ex)
{
return RedirectToError(ex.Message); // Exposes internal details!
}
✅ Solution:
catch (Exception ex)
{
SystemInfo.Error(ex);
return RedirectToError("An error occurred. Please try again.");
}
Real-World Scenarios
Scenario 1: Multi-Step Approval Workflow
public class ApprovalWorkflowController : AspxController
{
private OpportunityService _service = new OpportunityService();
// View pending approvals
[Authorize(Roles = "Sales Manager,Sales Director,VP Sales")]
public override ActionResponse Index()
{
var opportunities = Database.Query<Opportunity>()
.Where(o => o.Status == "Pending Approval")
.OrderBy(o => o.SubmittedDate)
.ToList();
// Filter by approval level
if (UserInfo.Roles.Contains("Sales Manager") &&
!UserInfo.Roles.Contains("Sales Director"))
{
// Managers only see opportunities under $50K
opportunities = opportunities.Where(o => o.Amount < 50000).ToList();
}
return View(opportunities);
}
// Approve opportunity
[HttpPost]
[ValidateAntiForgeryToken]
[Authorize(Roles = "Sales Manager,Sales Director,VP Sales")]
public ActionResponse Approve(string id, string comments)
{
try
{
var opp = Database.Retrieve<Opportunity>(id);
if (opp == null)
return PageNotFound();
// Validate approval authority
if (!_service.CanApprove(opp, UserInfo))
{
AspxPage.AddError("You do not have authority to approve this opportunity.");
return RedirectToAction("Details", new { id = id });
}
// Check if additional approval needed
var nextApprover = _service.GetNextApprover(opp);
if (nextApprover != null)
{
// Route to next approver
opp.Status = $"Pending {nextApprover} Approval";
opp.CurrentApprover = nextApprover;
Database.Update(opp);
// Notify next approver
_service.SendApprovalNotification(opp, nextApprover);
AspxPage.AddMessage($"Opportunity routed to {nextApprover} for approval.");
}
else
{
// Final approval
opp.Status = "Approved";
opp.ApprovedBy = UserInfo.Name;
opp.ApprovedDate = DateTime.Now;
opp.ApprovalComments = comments;
Database.Update(opp);
// Notify submitter
_service.SendFinalApprovalNotification(opp);
AspxPage.AddMessage("Opportunity approved successfully.");
}
return RedirectToAction("Index");
}
catch (Exception ex)
{
SystemInfo.Error(ex);
return RedirectToError("An error occurred during approval.");
}
}
// Reject opportunity
[HttpPost]
[ValidateAntiForgeryToken]
[Authorize(Roles = "Sales Manager,Sales Director,VP Sales")]
public ActionResponse Reject(string id, string reason)
{
var opp = Database.Retrieve<Opportunity>(id);
if (opp == null)
return PageNotFound();
opp.Status = "Rejected";
opp.RejectedBy = UserInfo.Name;
opp.RejectedDate = DateTime.Now;
opp.RejectionReason = reason;
Database.Update(opp);
// Notify submitter
_service.SendRejectionNotification(opp);
AspxPage.AddMessage("Opportunity rejected.");
return RedirectToAction("Index");
}
}
Scenario 2: Batch Operations with Progress Tracking
public class BatchOperationsController : AspxController
{
// Start batch operation
[HttpPost]
[Authorize(Roles = "Administrator")]
public ActionResponse StartBatchUpdate(string criteria)
{
try
{
// Get records to update
var contacts = Database.Query<Contact>()
.Where(c => c.Status == criteria)
.ToList();
// Store batch job info
var batchJob = new BatchJob
{
JobType = "Contact Update",
TotalRecords = contacts.Count,
ProcessedRecords = 0,
Status = "In Progress",
StartedBy = UserInfo.Name,
StartedDate = DateTime.Now
};
Database.Insert(batchJob);
// Process in background (simplified)
ProcessBatchInBackground(batchJob.Id, contacts);
AspxPage.AddMessage($"Batch job started. Processing {contacts.Count} records.");
return RedirectToAction("ViewBatchJob", new { id = batchJob.Id });
}
catch (Exception ex)
{
SystemInfo.Error(ex);
return RedirectToError("Failed to start batch operation.");
}
}
// View batch job progress
[Authorize(Roles = "Administrator")]
public ActionResponse ViewBatchJob(string id)
{
var batchJob = Database.Retrieve<BatchJob>(id);
if (batchJob == null)
return PageNotFound();
return View(batchJob);
}
// Get batch job status (AJAX)
[HandleExceptionsForJson]
[Authorize(Roles = "Administrator")]
public ActionResponse GetBatchStatus(string id)
{
var batchJob = Database.Retrieve<BatchJob>(id);
if (batchJob == null)
return Json(new { error = "Job not found" }, 404, JsonRequestBehavior.AllowGet);
return Json(new
{
status = batchJob.Status,
totalRecords = batchJob.TotalRecords,
processedRecords = batchJob.ProcessedRecords,
successCount = batchJob.SuccessCount,
errorCount = batchJob.ErrorCount,
percentComplete = (batchJob.ProcessedRecords * 100) / batchJob.TotalRecords
}, JsonRequestBehavior.AllowGet);
}
private void ProcessBatchInBackground(string batchJobId, List<Contact> contacts)
{
// In a real implementation, this would be a separate background process // For this example, we'll show the logic
Task.Run(() =>
{
var batchJob = Database.Retrieve<BatchJob>(batchJobId);
int successCount = 0;
int errorCount = 0;
foreach (var contact in contacts)
{
try
{
// Perform update operation
contact.LastModifiedDate = DateTime.Now;
contact.LastModifiedBy = "Batch Process";
Database.Update(contact);
successCount++;
}
catch (Exception ex)
{
SystemInfo.Error(ex);
errorCount++;
}
// Update progress every 10 records
if ((successCount + errorCount) % 10 == 0)
{
batchJob.ProcessedRecords = successCount + errorCount;
batchJob.SuccessCount = successCount;
batchJob.ErrorCount = errorCount;
Database.Update(batchJob);
}
}
// Final update
batchJob.ProcessedRecords = contacts.Count;
batchJob.SuccessCount = successCount;
batchJob.ErrorCount = errorCount;
batchJob.Status = "Completed";
batchJob.CompletedDate = DateTime.Now;
Database.Update(batchJob);
});
}
}
// Poll for batch job status
function monitorBatchJob(jobId) {
const interval = setInterval(() => {
fetch(`/acls/BatchOperations/GetBatchStatus?id=${jobId}`)
.then(response => response.json())
.then(data => {
// Update progress bar
document.getElementById('progressBar').style.width = data.percentComplete + '%';
document.getElementById('progressText').textContent =
`${data.processedRecords} / ${data.totalRecords} records processed`;
// Update counts
document.getElementById('successCount').textContent = data.successCount;
document.getElementById('errorCount').textContent = data.errorCount;
// Stop polling when complete
if (data.status === 'Completed') {
clearInterval(interval);
document.getElementById('statusMessage').textContent = 'Batch job completed!';
}
});
}, 2000); // Poll every 2 seconds
}
Scenario 3: Complex Search with Filters
public class AdvancedSearchController : AspxController
{
public override ActionResponse Index()
{
// Display search form
PopulateFilterOptions();
return View();
}
[HttpPost]
public ActionResponse Search(SearchCriteria criteria)
{
try
{
// Build dynamic query
var query = Database.Query<Opportunity>();
// Apply filters
if (!string.IsNullOrEmpty(criteria.Name))
query = query.Where(o => o.Name.Contains(criteria.Name));
if (!string.IsNullOrEmpty(criteria.Stage))
query = query.Where(o => o.Stage == criteria.Stage);
if (!string.IsNullOrEmpty(criteria.Type))
query = query.Where(o => o.Type == criteria.Type);
if (criteria.MinAmount.HasValue)
query = query.Where(o => o.Amount >= criteria.MinAmount.Value);
if (criteria.MaxAmount.HasValue)
query = query.Where(o => o.Amount <= criteria.MaxAmount.Value);
if (criteria.StartDate.HasValue)
query = query.Where(o => o.CloseDate >= criteria.StartDate.Value);
if (criteria.EndDate.HasValue)
query = query.Where(o => o.CloseDate <= criteria.EndDate.Value);
if (!string.IsNullOrEmpty(criteria.OwnerId))
query = query.Where(o => o.OwnerId == criteria.OwnerId);
// Apply account filter for portal users
if (UserInfo.IsPortalUser)
query = query.Where(o => o.AccountId == UserInfo.Account.Id);
// Execute query with pagination
int pageSize = 50;
var results = query
.OrderByDescending(o => o.CreatedDate)
.Skip((criteria.Page - 1) * pageSize)
.Take(pageSize)
.ToList();
// Get total count for pagination
int totalCount = query.Count();
// Prepare view model
var viewModel = new SearchResultsViewModel
{
Results = results,
Criteria = criteria,
TotalCount = totalCount,
PageSize = pageSize,
TotalPages = (int)Math.Ceiling((double)totalCount / pageSize)
};
PopulateFilterOptions();
return View("Results", viewModel);
}
catch (Exception ex)
{
SystemInfo.Error(ex);
AspxPage.AddError("An error occurred during search. Please try again.");
PopulateFilterOptions();
return View("Index");
}
}
// Export search results
public ActionResponse ExportResults(SearchCriteria criteria)
{
// Rebuild query (same logic as Search action)
var query = BuildSearchQuery(criteria);
var results = query
.OrderByDescending(o => o.CreatedDate)
.ToList();
// Generate CSV
var csv = new StringBuilder();
csv.AppendLine("Name,Stage,Type,Amount,Close Date,Owner");
foreach (var opp in results)
csv.AppendLine($"{opp.Name},{opp.Stage},{opp.Type},{opp.Amount},{opp.CloseDate:yyyy-MM-dd},{opp.OwnerName}");
var bytes = Encoding.UTF8.GetBytes(csv.ToString());
return File(bytes, "text/csv", $"search_results_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
}
private IQueryable<Opportunity> BuildSearchQuery(SearchCriteria criteria)
{
var query = Database.Query<Opportunity>();
if (!string.IsNullOrEmpty(criteria.Name))
query = query.Where(o => o.Name.Contains(criteria.Name));
if (!string.IsNullOrEmpty(criteria.Stage))
query = query.Where(o => o.Stage == criteria.Stage);
if (!string.IsNullOrEmpty(criteria.Type))
query = query.Where(o => o.Type == criteria.Type);
if (criteria.MinAmount.HasValue)
query = query.Where(o => o.Amount >= criteria.MinAmount.Value);
if (criteria.MaxAmount.HasValue)
query = query.Where(o => o.Amount <= criteria.MaxAmount.Value);
if (criteria.StartDate.HasValue)
query = query.Where(o => o.CloseDate >= criteria.StartDate.Value);
if (criteria.EndDate.HasValue)
query = query.Where(o => o.CloseDate <= criteria.EndDate.Value);
if (!string.IsNullOrEmpty(criteria.OwnerId))
query = query.Where(o => o.OwnerId == criteria.OwnerId);
if (UserInfo.IsPortalUser)
query = query.Where(o => o.AccountId == UserInfo.Account.Id);
return query;
}
private void PopulateFilterOptions()
{
// Populate dropdown options
DataBag.Stages = new List<string> { "Prospecting", "Qualification", "Proposal", "Negotiation", "Closed Won", "Closed Lost" };
DataBag.Types = new List<string> { "New Business", "Existing Business", "Renewal" };
// Load owners
var owners = Database.Query<User>()
.Where(u => u.IsActive)
.OrderBy(u => u.Name)
.Select(u => new { u.Id, u.Name })
.ToList();
DataBag.Owners = owners;
}
}
public class SearchCriteria
{
public string Name { get; set; }
public string Stage { get; set; }
public string Type { get; set; }
public decimal? MinAmount { get; set; }
public decimal? MaxAmount { get; set; }
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
public string OwnerId { get; set; }
public int Page { get; set; } = 1;
}
Migration & Maintenance
Versioning Controllers
Use versioning for backward compatibility when making breaking changes.
// Version 1
public class ContactApiController : AspxController
{
[HandleExceptionsForJson]
public ActionResponse GetContact(string id)
{
var contact = Database.Retrieve<Contact>(id);
return Json(new {
id = contact.Id,
name = contact.Name,
email = contact.Email
}, JsonRequestBehavior.AllowGet);
}
}
// Version 2 - with additional fields
public class ContactApiV2Controller : AspxController
{
[HandleExceptionsForJson]
public ActionResponse GetContact(string id)
{
var contact = Database.Retrieve<Contact>(id);
return Json(new {
id = contact.Id,
firstName = contact.FirstName,
lastName = contact.LastName,
fullName = $"{contact.FirstName} {contact.LastName}",
email = contact.Email,
phone = contact.Phone,
account = new {
id = contact.AccountId,
name = contact.Account.Name
}
}, JsonRequestBehavior.AllowGet);
}
}
URLs:
- V1:
/acls/ContactApi/GetContact?id=123 - V2:
/acls/ContactApiV2/GetContact?id=123
Deprecation Strategy
Gracefully deprecate old endpoints.
public class LegacyApiController : AspxController
{
[HandleExceptionsForJson]
[Obsolete("This endpoint is deprecated. Use ContactApiV2 instead.")]
public ActionResponse GetContact(string id)
{
// Log usage of deprecated endpoint
LogDeprecatedEndpointUsage("GetContact", UserInfo.UserId);
// Still provide functionality but warn users
var contact = Database.Retrieve<Contact>(id);
return Json(new {
deprecated = true,
message = "This endpoint is deprecated. Please migrate to /acls/ContactApiV2/GetContact",
migrationGuide = "https://docs.yourportal.com/api/migration",
data = contact
}, JsonRequestBehavior.AllowGet);
}
private void LogDeprecatedEndpointUsage(string endpoint, string userId)
{
try
{
var log = new DeprecationLog
{
Endpoint = endpoint,
UserId = userId,
Timestamp = DateTime.Now
};
Database.Insert(log);
}
catch
{
// Fail silently
}
}
}
Documentation Best Practices
Add XML comments to your controllers.
/// <summary>
/// Manages contact-related operations including CRUD and search.
/// </summary>
public class ContactController : AspxController
{
/// <summary>
/// Displays a paginated list of contacts.
/// </summary>
/// <param name="page">Page number (1-based)</param>
/// <param name="pageSize">Number of records per page (default: 25)</param>
/// <returns>Contact list view</returns>
[AuthorizeAction(Entity = "Contact", Action = StandardAction.Read)]
public override ActionResponse Index(int page = 1, int pageSize = 25)
{
int skip = (page - 1) * pageSize;
var contacts = Database.Query<Contact>()
.OrderBy(c => c.LastName)
.Skip(skip)
.Take(pageSize)
.ToList();
return View(contacts);
}
/// <summary>
/// Creates a new contact record.
/// </summary>
/// <param name="model">Contact data from form submission</param>
/// <returns>Redirect to contact details on success, or form with errors on validation failure</returns>
/// <remarks>
/// Portal users: New contacts are automatically associated with the user's account.
/// Employee users: Can associate contact with any account.
/// </remarks>
[HttpPost]
[ValidateAntiForgeryToken]
[AuthorizeAction(Entity = "Contact", Action = StandardAction.Create)]
public ActionResponse Create(Contact model)
{
if (ModelState.IsValid)
{
// Portal users: force account association
if (UserInfo.IsPortalUser)
model.AccountId = UserInfo.Account.Id;
Database.Insert(model);
AspxPage.AddMessage("Contact created successfully!");
return RedirectToAction("Details", new { id = model.Id });
}
return View(model);
}
}
Summary
This comprehensive guide covered advanced controller patterns including:
✅ Design Patterns - Repository, Service Layer, and Factory patterns for cleaner code
✅ Error Handling - Centralized error handling, graceful degradation, and retry logic
✅ Performance Optimization - Query optimization, pagination, caching, and lazy loading
✅ Testing - Manual testing checklists, test data helpers, and debugging techniques
✅ Common Pitfalls - Solutions for null checking, PRG pattern, data loading, validation, and error messages
✅ Real-World Scenarios - Multi-step workflows, batch operations, and complex search
✅ Migration & Maintenance - Versioning, deprecation strategies, and documentation
Final Checklist
Before deploying a controller to production:
Security
- [ ] All actions have appropriate authorization
- [ ] User input is validated
- [ ] Account isolation enforced for portal users
- [ ] CSRF protection on POST actions
- [ ] No sensitive data exposed in errors
Performance
- [ ] Database queries are optimized
- [ ] Pagination implemented for large datasets
- [ ] N+1 query problems eliminated
- [ ] Expensive operations cached when appropriate
Error Handling
- [ ] Try-catch blocks for external calls
- [ ] User-friendly error messages
- [ ] Errors logged for troubleshooting
- [ ] Null checks before using objects
Testing
- [ ] Tested with different user types
- [ ] Tested with different security roles
- [ ] Edge cases covered
- [ ] Invalid input handled gracefully
Code Quality
- [ ] Controllers follow single responsibility principle
- [ ] Complex logic extracted to service classes
- [ ] Code is well-commented
- [ ] No hardcoded values
User Experience
- [ ] Success messages displayed
- [ ] Error messages are helpful
- [ ] PRG pattern used for POST actions
- [ ] Loading indicators for slow operations
Congratulations! 🎉
You've completed the comprehensive Magentrix Controller Developer Guide! You now have the knowledge to:
- Build secure, high-performance controllers
- Implement complex business logic
- Handle errors gracefully
- Test and maintain your code
- Follow best practices and patterns
Next Steps
- Practice - Build real controllers in your Magentrix environment
- Experiment - Try different patterns and see what works best
- Review - Refer back to this guide when needed
- Share - Help other developers learn these concepts
Additional Resources
- Magentrix REST API Documentation - For building integrations
- Active Pages Guide - For creating the UI layer
- Database & Entities Documentation - For data modeling
💡 Remember: Great controllers are secure, performant, maintainable, and user-friendly. Keep learning, keep improving, and happy coding!