Table of Contents


IRIS App and C# Controllers

Overview

Active Controllers are server-side C# classes stored in .ctrl files that handle HTTP requests from IRIS Apps. They are used exclusively when the Magentrix SDK and REST API V3 cannot fulfil a requirement — for example, complex business logic, multi-entity transactions, background scheduling, or operations requiring elevated privileges.

⚠️ Warning: Only build a custom controller when the SDK's query(), retrieve(), create(), edit(), or delete() methods cannot satisfy the requirement. Controllers add deployment overhead and require a server publish and hard browser refresh to take effect. See Architecture Decision Guide if you are unsure which path to take.

File Conventions

PropertyValue
File extension.ctrl — not .cs
Locationsrc/Controllers/ in the Magentrix workspace
Base classAspxAsyncController — required for IRIS App controllers. Provides built-in exception handling and async support.
Return typeActionResponse — not ActionResult or IActionResult
Comments before classNot permitted — the class declaration must be the first line
RoutingAutomatic — /acls/{ControllerName}/{MethodName}

Controller Structure

A minimal controller with one GET and one POST method:

public class AccountController : AspxAsyncController
{
    /// <summary>
    /// Returns active accounts.
    /// </summary>
    public async Task<ActionResponse> GetActive()
    {
        var accounts = await Database.Query<Account>()
            .Where(f => f.IsActive == true)
            .OrderBy(f => f.Name)
            .ToListAsync();

        return return IrisData(accounts);
    }

    /// <summary>
    /// Creates a new account.
    /// </summary>
    [HttpPost]
    public async Task<ActionResponse> Create(Account model)
    {
        await Database.CreateAsync(model);
        return Json(new { id = result.Id });
    }
}

This controller creates two endpoints:

  • GET /acls/account/getactive
  • POST /acls/account/create

Critical Rules

The following rules apply to every controller. Violating any of them produces silent failures or incorrect behaviour that can be difficult to debug.

1. Always Extend AspxAsyncController

All IRIS App controllers must extend AspxAsyncController. This base class automatically formats any unhandled exception as a JSON error response instead of an HTML error page. The [ContentNegotiationExceptionFilter] attribute is built into the AsyncController base class — you do not need to apply it manually.

💡 Note:AspxAsyncController cannot return ViewResponse. If a controller needs to render server-side views, it must use AspxController instead. For all IRIS App controllers that return JSON, always use AspxAsyncController.
// ✅ Correct — exception filter is built-in
public class OrderController: AspxAsyncController
{ ... }

// ❌ Incorrect — AspxController does not include the exception filter
public class OrderController: AspxController
{ ... }

// ⚠️ Redundant but harmless — filter is already inherited
[ContentNegotiationExceptionFilter]
public class OrderController: AspxAsyncController
{ ... }


2. GET Methods Must Use Json(data, true)

The second parameter true is the allowGet flag. Without it, ASP.NET blocks JSON serialisation on GET requests and returns an HTML error page instead.

// ✅ Correct — allowGet = true
public ActionResponse GetSummary()
{
    return Json(new { data = summary }, true);
}

// ❌ Incorrect — returns HTML on GET
public ActionResponse GetSummary()
{
    return Json(new { data = summary });
}


3. POST Methods Use Json(data) — No Second Parameter

// ✅ Correct
[HttpPost]
public ActionResponse Save(Account model)
{
    return Json(new { data = summary });
}


4. Supported HTTP Verbs

AspxAsyncController supports all standard HTTP verbs. Use the appropriate verb attribute for each operation:

VerbAttributeUse For
GETnone — defaultRead operations
POST[HttpPost]Create operations
PUT[HttpPut]Full-record update operations
PATCH[HttpPatch]Partial update operations
DELETE[HttpDelete]Delete operations

Using semantically correct HTTP verbs improves API clarity. However, [HttpPost] remains acceptable for all state-changing operations if you prefer simplicity.

// ✅ Correct — semantically appropriate verbs
[HttpPut]
public async Task<ActionResponse> Update(Account model)
{ ... }

[HttpPatch]
public async Task<ActionResponse> UpdateStatus(string id, string status)
{ ... }

[HttpDelete]
public async Task<ActionResponse> Remove(string id)
{ ... }

// ✅ Also correct — POST for all writes (simpler approach)
[HttpPost]
public async Task<ActionResponse> UpdateStatus(string id, string status)
{ ... }

 

5. Do Not Add [HttpGet]

Public methods without an HTTP verb attribute are GET by default. Adding [HttpGet] explicitly is unnecessary and adds noise.

 

6. Remove [ValidateAntiForgeryToken] from SDK-Called Methods

The Magentrix SDK does not send anti-forgery tokens. If [ValidateAntiForgeryToken] is present on a POST method called by an IRIS App, every request will fail with a 400 error.

// ✅ Correct — no anti-forgery token for SDK-called methods
[HttpPost]
public async Task<ActionResponse> ProcessOrder(string accountId)
{ ... }

// ❌ Incorrect — SDK calls will always fail
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResponse> ProcessOrder(string accountId)
{ ... }

 

7. There Is No [SystemMode] Attribute

There is no attribute for elevating privileges. Use DatabaseOptions { SystemMode = true } as a parameter on individual database calls, or use the .ToListAsAdmin() extension method on queries.

// ✅ Correct — SystemMode per database call
var result = await Database.CreateAsync(account, new DatabaseOptions
{
    SystemMode = true
});

// ✅ Correct — admin query
var accounts = await Database.Query<Account>()
    .Where(f => f.IsActive == true)
    .ToListAsAdminAsync();

// ❌ Incorrect — attribute does not exist
[SystemMode]
public async Task<ActionResponse> AdminTask()
{ ... }

 

8. There Is No [Route] Attribute

Routing is automatic and cannot be customized. The URL is always /acls/{ControllerName}/{MethodName}.

⚠️ Warning: The Controller suffix is stripped from the class name when building the URL. AccountController maps to /acls/Account/, not /acls/AccountController/.

Routing

Controller routes follow a fixed pattern. The Controller suffix is removed from the class name automatically.

Class NameMethod NameRoute
AccountControllerGetActive()GET /acls/account/getactive
OrderControllerProcessOrder()POST /acls/order/processorder
ReportControllerGetSummary()GET /acls/report/getsummary
ReportControllerIndex()GET /acls/report (default action)

Calling a controller from the IRIS App via the SDK:

// GET request
const data = await dataService.execute(
    '/acls/report/getsummary',
    null,
    RequestMethod.get
);

// POST request
const result = await dataService.execute(
    '/acls/order/processorder',
    { accountId: '00I00000000003x0001', amount: 500 }
);

// PUT request
const result = await dataService.execute(
    '/acls/account/update',
    { Id: '00I00000000003x0001', Name: 'Updated Name' },
    RequestMethod.put
);

// DELETE request
const result = await dataService.execute(
    '/acls/opportunity/remove',
    { opportunityId: '006000000000xyz0001' },
    RequestMethod.delete
);

Parameter Binding

Controller methods receive parameters differently depending on the HTTP method.

GET — Query String Parameters

Parameters are passed as query string values and bound automatically by name.

// Route: GET /acls/account/search?status=active&limit=25
public async Task<ActionResponse> Search(string status, int? limit)
{
    var query = Database.Query<Account>()
        .Where(f => f.Status == status);

    if (limit.HasValue)
        query = query.Limit(limit.Value);

    return IrisData(await query.ToListAsync());
}


POST — JSON Body

Complex objects are deserialized from the JSON request body automatically. The property names in the JSON payload must match the C# model field names.

// Route: POST /acls/account/create
// Body: { "Name": "Acme Corp", "Status": "Active" }
[HttpPost]
public async Task<ActionResponse> Create(Account model)
{
    // model.Name and model.Status are populated from JSON body
    var result = await Database.CreateAsync(model);
    return Json(new { id = result.Id });
}


Error Handling

All controller methods should follow a consistent try/catch pattern. Always unwrap TargetInvocationException — the platform wraps exceptions in this type, so without the unwrap the error message returned to the client is always the generic wrapper message rather than the underlying cause.

[HttpPost]
public async Task<ActionResponse> ProcessOrder(string accountId, decimal amount)
{
    if (string.IsNullOrEmpty(accountId))
        throw new ModelValidationException("Account ID is required.");

    if (amount <= 0)
        throw new ModelValidationException("Amount must be greater than zero.");

    // Business logic here
    // ...

    return Json(new { success = true });
}

Async Methods

AspxAsyncController supports async/await for non-blocking database and I/O operations. Declare the return type as async Task<ActionResponse> instead of ActionResponse.

/// <summary>
/// Asynchronously retrieves account data and related contacts.
/// </summary>
public async Task<ActionResponse> GetAccountWithContacts(string accountId)
{
    if (string.IsNullOrEmpty(accountId))
        throw new ModelValidationException("accountId is required");

    var account = await Database.RetrieveAsync<Account>(accountId);

    if (account == null)
        throw new ModelValidationException("Account not found");

    var contacts = await Database.Query<Contact>()
        .Where(f => f.AccountId == accountId)
        .OrderBy(f => f.LastName)
        .ToListAsync();

    return IrisData(new { account, contacts });
}

/// <summary>
/// Asynchronously creates a record.
/// </summary>
[HttpPost]
public async Task<ActionResponse> CreateAsync(Account model)
{
    var result = await Database.CreateAsync(model);

    if (result.HasError)
        throw new ModelValidationException(result.Errors[0].Message);

    return Json(new { id = result.Id });
}
Use async when…Async not needed when…
Queries may return large result setsSingle simple query operations
Multiple sequential database operationsIn-memory computations only
External HTTP calls or I/O-bound workMethods already fast enough synchronously


System Privileges

Some operations require bypassing user-level permissions — for example, automated processes, system integrations, or administrative background tasks. Use DatabaseOptions { SystemMode = true } on individual database calls, or .ToListAsAdmin() on queries.

// Admin query — bypasses user permission filtering
var allAccounts = await Database.Query<Account>()
    .Where(f => f.IsActive == true)
    .ToListAsAdminAsync();

// Admin create — bypasses sharing rules
var result = await Database.CreateAsync(record, new DatabaseOptions
{
    SystemMode = true
});
⚠️ Warning: Never use SystemMode = true for user-facing operations. It bypasses all field-level security and sharing rules. Document explicitly in code comments why elevated privileges are required for every instance of its use.


Complete Controller Example

A realistic controller combining GET, POST, validation, error handling, and SystemMode:

public class OpportunityController : AspxAsyncController
{
    /// <summary>
    /// Returns open opportunities for an account.
    /// </summary>
    /// <param name="accountId">The account ID to filter by.</param>
    public async Task<ActionResponse> GetOpen(string accountId)
    {
        if (string.IsNullOrEmpty(accountId))
            throw new ModelValidationException("accountId is required.");

        var opportunities = await Database.Query<Opportunity>()
            .Where(f => f.AccountId == accountId && f.IsClosed == false)
            .OrderBy(f => f.CloseDate)
            .ToListAsync()

        return IrisData(opportunities);
    }

    /// <summary>
    /// Closes an opportunity and logs the outcome.
    /// </summary>
    [HttpPost]
    public async Task<ActionResponse> Close(string opportunityId, string outcome)
    {
        if (string.IsNullOrEmpty(opportunityId))
            throw new ModelValidationException("Opportunity ID not provided"); 

        var opportunity = await Database.RetrieveAsync<Opportunity>(opportunityId);

        if (opportunity == null)
            throw new ModelValidationException("Opportunity not found");

        opportunity.IsClosed = true;
        opportunity.Outcome__c = outcome;

        await Database.EditAsync(opportunity);

        var log = new OpportunityLog__c;
        {
            OpportunityId__c = opportunityId,
            Outcome__c = outcome,
            ClosedBy__c = UserInfo.UserId,
            ClosedOn__c = DateTime.UtcNow
        };

        await Database.CreateAsync(log);

        return Json(new { success = true });
    }

    /// <summary>
    /// Deletes an opportunity by ID.
    /// </summary>
    [HttpDelete]
    public async Task<ActionResponse> Remove(string opportunityId)
    {
        if (string.IsNullOrEmpty(opportunityId))
            throw new ModelValidationException("opportunityId is required.");

        await Database.DeleteAsync<Opportunity>(opportunityId);
        return Json(new { success = true });
    }
}

Publishing Controllers

Controller files are server-side and must be published to the Magentrix platform before changes take effect. Unlike IRIS App Vue changes — which update on the next page load — controller changes require an explicit publishing step followed by a hard browser refresh.

magentrix publish

After publishing, hard refresh the browser (Ctrl+Shift+R on Windows/Linux, Cmd+Shift+R on macOS) to clear cached responses and load the updated controller.

💡 Note: If a controller change does not appear to take effect after publishing, confirm the publish completed without errors and that you performed a hard refresh rather than a standard page reload.


What to Read Next

If you want to…Go to…
Use the Database class inside a controllerDatabase Class Reference
Call a controller from an IRIS AppMagentrix SDK Reference — execute()
Understand when to build a controller vs use the SDKArchitecture Decision Guide
See all controller attributes and annotationsController Attributes & Annotations
Last updated on 5/14/2026

Attachments