Method Event

Summary

Methods are custom operations defined on entities using the [Method] attribute and implemented through the event system. They are automatically exposed as OData endpoints, enabling clients to invoke business operations through the API.

When does it fire?

A method fires when explicitly called — either from code or via an HTTP request to the OData API endpoint.

Defining Methods

Methods are declared on the entity class with the [Method] attribute:

[ApiEntity]
public partial class SalesOrder
{
    [Method("Reorder from another order", MethodType.Modify)]
    public partial void ReorderFrom(Guid salesOrderGuid);

    [Method("Calculate line total", MethodType.Read)]
    public partial decimal CalculateLineTotal(decimal taxRate, int quantity, decimal discount = 0m);

    [Method("Get all open orders", MethodType.Read)]
    public static partial IQueryable<SalesOrder> GetOpenOrders(string customerId);
}

MethodType

Type HTTP Verb Description
MethodType.Read GET Returns data without modifying state
MethodType.Modify POST Changes data (can save, delete, etc.)

Parameter rules

  • Required: Non-nullable parameters without a default value
  • Optional: Nullable parameters or parameters with a default value
  • Missing required parameters return 400 BadRequest

Implementing Methods

Methods are declared in the model and implemented with subscribers in the logic class.

Instance method

entity.Method(e => e.MethodName).Do((entity, args) =>
{
    // args.<parameterName> for each method parameter
    // args.GetService<T>() for DI services
    // args.GetEntities<T>() for data access
    // Method parameters are exposed on args by the names declared in the method signature.
});

Static method

entity.Method(_ => EntityType.StaticMethodName).Do(args =>
{
    // No entity instance — static context
    return args.GetEntities<EntityType>().Where(...);
});

Scenarios

1. Method that changes data (POST)

This method reorders by copying details from a previous order into a new sales order. This is a Modify method because it changes data.

[ApiEntity]
public partial class SalesOrder
{
    [Method("Reorder from another order", MethodType.Modify)]
    public partial void ReorderFrom(Guid salesOrderGuid);
}
[Logic]
public class SalesOrderBL(SalesOrder.Logic salesOrder)
{
    [RegisterLogic]
    public void ReorderFromMethod()
    {
        salesOrder.Method(e => e.ReorderFrom).Do((order, args) =>
        {
            var context = args.Context;
            var sourceOrder = args.GetEntity<SalesOrder>(args.salesOrderGuid);
            if (sourceOrder == null)
                throw new InvalidOperationException("Source order not found");

            if (order.Details.Count > 0)
                throw new InvalidOperationException("Target order already has details");

            order.SellToCustomer = sourceOrder.SellToCustomer;
            order.Description = sourceOrder.Description;

            foreach (var sourceLine in sourceOrder.Details)
            {
                _ = new SalesOrderDetail(context)
                {
                    SalesOrder = order,
                    Product = sourceLine.Product,
                    Quantity = sourceLine.Quantity,
                    UnitPrice = sourceLine.UnitPrice,
                    Description = sourceLine.Description
                };
            }
        });
    }
}

API call:

POST /api/SalesOrder({orderId})/ReorderFrom?salesOrderGuid={sourceOrderId}

2. Method that only reads data (GET)

This method calculates a line's total that includes tax. It is a Read method that computes a value without modifying data.

[ApiEntity]
public partial class SalesOrder
{
    [Method("Calculate line total", MethodType.Read)]
    public partial decimal CalculateLineTotal(decimal taxRate, int quantity, decimal discount = 0m);
}
[Logic]
public class SalesOrderBL(SalesOrder.Logic salesOrder)
{
    [RegisterLogic]
    public void CalculateLineTotalMethod()
    {
        salesOrder.Method(e => e.CalculateLineTotal).Do((order, args) =>
        {
            var subtotal = order.UnitPrice * args.quantity;
            var discounted = subtotal - (subtotal * args.discount);
            return discounted + (discounted * args.taxRate);
        });
    }
}

API call:

GET /api/SalesOrder({orderId})/CalculateLineTotal()?taxRate=0.08&quantity=5&discount=0.1

3. Static method to query open orders (GET)

A static method does not operate on a specific entity instance — it works at the entity type level.

[ApiEntity]
public partial class SalesOrder
{
    [Method("Get all open orders", MethodType.Read)]
    public static partial IQueryable<SalesOrder> GetOpenOrders(string customerId);
}
[Logic]
public class SalesOrderBL(SalesOrder.Logic salesOrder)
{
    [RegisterLogic]
    public void GetOpenOrdersMethod()
    {
        salesOrder.Method(e => SalesOrder.GetOpenOrders).Do(args =>
        {
            return args.GetEntities<SalesOrder>()
                .Where(o => o.SellToCustomer!.Id == args.customerId
                         && o.Status == SalesOrderStatus.Open);
        });
    }
}

API call:

Normal OData parameters such as $select, $expand, and $filter can be used when a method returns a collection of entities.

GET /api/SalesOrder/GetOpenOrders()?customerId=CUST001&$select=Guid,DocNumber,Description,TotalPrice

4. Parameterless method

Methods with no parameters are also supported, but when calling the method through OData, you must use parentheses.

[ApiEntity]
public partial class Product
{
    [Method("Get default markup", MethodType.Read)]
    public partial decimal GetDefaultMarkup();
}
[Logic]
public class ProductBL(Product.Logic product)
{
    [RegisterLogic]
    public void GetDefaultMarkupMethod()
    {
        product.Method(e => e.GetDefaultMarkup).Do((p, args) => 1.5m);
    }
}

API call (note the parentheses for GET):

GET /api/Product({productId})/GetDefaultMarkup()

Note: The () is required for GET requests (Read methods) to indicate a function call. POST requests (Modify methods) do not need parentheses.

5. Computing a property when a method executes

This method posts transactions for a journal. When the method runs, the IsPosted status is computed.

[ApiEntity]
public partial class Journal
{
    [Property<DataTypes.Boolean>("IsPosted")]
    public partial bool IsPosted { get; } // Read-only properties can only be computed.
    
    [Method("Post", MethodType.Modify)]
    public partial void Post();
}
[Logic]
public class JournalBL(Journal.Logic journal)
{
    [RegisterLogic]
    public void Posting()
    {
        journal.Compute(j => j.IsPosted)
            .From(j => true) // Sets when the Post() method completes.
            .DirtyByMethod(j => j.Post);
    }
}

6. Disabling a method with DisableIf (Disabled UI button)

The method is disabled when IsPosted is true. If there are multiple subscribers with DisableIf, then any expression returning true disables the method.

When to use DisableIf vs AbortIf: In the UI, the button will be disabled. Only use DisableIf when the condition is performant and when it is obvious to the user why they cannot run the method.

Performance: Only use a performant condition because the condition will run on each round trip to the client because the client needs to know if the command condition changed.

[Logic]
public class JournalBL(Journal.Logic journal)
{
    [RegisterLogic]
    public void Posting()
    {
        journal.Method(j => j.Post)
            .DisableIf(j => j.IsPosted) // Disable if it is already posted.
            .Do((order, args) =>
            {
                CreateJournalTransactions(order, args);
            });
    }
}

Disabled method names are included in the record's RecordReadOnly field. See Read-only and Method Buttons.

7. Aborting a method with AbortIf

The following method will be aborted with a message if the invoice is posted.

When to use AbortIf vs DisableIf: The user will be able to run the method. The advantage of AbortIf is that a message can tell the user why he can't run the method. Secondly, the conditional expression only runs when the user attempts to run the method.

[ApiEntity]
public partial class SalesInvoice
{
    [Method("Update prices", MethodType.Modify)]
    public partial void UpdatePrices();
}
[Logic]
public class SalesInvoiceBL(SalesInvoice.Logic salesInvoice)
{
    [RegisterLogic]
    public void PriceManagement()
    {
        salesInvoice.Method(i => i.UpdatePrices)
            .AbortIf(i => i.IsPosted) // Abort if it is already posted.
            .WithMessage(i => $"{i.DocNumber} is already posted and the prices cannot be changed")
            .Do((order, args) =>
            {
                foreach (var detail in order.Details)
                {
                    detail.RecalculatePrice();
                }
            });
    }
}

8. Aborting during method execution

A method can run several subscribers, but this can create a problem where some subscribers run and others do not, leaving the method partially executed.

Rollback on aborted method: AbortIf and DisableIf prevent the method from running. All conditions are checked before executing any Do() subscribers. In these cases, there is no need to undo changes. However, if args.AbortWithMessage() is called in the method, then UndoOnAbort rolls back changes. Undos replay in reverse order.

mfg.Method(m => m.CompleteProcess).Do((mfg, args) =>  
{ 
    // Owning feature changes the quantity. This is the 1st subscriber.
    var previousQuantity = mfg.QuantityProduced;
    mfg.QuantityProduced += 10;
    args.UndoOnAbort(m => m.QuantityProduced = previousQuantity); // Register next to the change it reverses.
});

Only registered undos replay — a subscriber with no UndoOnAbort leaves its changes in place.

mfg.Method(m => m.CompleteProcess).Do((mfg, args) =>
{
    // Another feature adds shipping functionality with a 2nd subscriber.
    if (!CanBeShipped())
        args.AbortWithMessage(m => $"{mfg.QuantityProduced} items cannot be shipped. Process aborted.");
});

args.AbortWithMessage() aborts the method. Subscribers that already run are rolled back and subsequent subscribers do not run.

9. Save on completion

Avoid calling args.Context.SaveChanges(). Instead, set SaveOnCompletion = true to save once after the method finishes. The engine checks existing errors and required properties before the body runs; if they pass, every subscriber runs and changes are saved in a single commit. Many subscribers can share one final save. If one fails or the save fails, the undo handlers run for each method subscriber.

[Method("Run with save", MethodType.Modify, SaveOnCompletion = true)]
public partial void RunWithSave();
graph LR
    A["Pre-checks<br/>errors + required properties"] --> B["Run all subscribers"] --> C["SaveChanges once"]

Controlling execution

  • Abort a method before it runs, with a custom message (AbortIf).
  • Disable a method - block it and grey out its client button (DisableIf).
  • Undo changes when a method aborts or its final save fails (UndoOnAbort).

OData API Routes

Method Type HTTP Route
Instance Read GET /api/{Entity}({id})/MethodName()
Instance Modify POST /api/{Entity}({id})/MethodName
Static Read GET /api/{Entity}/MethodName()
Static Modify POST /api/{Entity}/MethodName

GET: Parameters are passed as query string values

POST: Parameters are passed via a JSON body.

Notes

  • Method implementations have full access to args.GetService<T>() and args.GetEntities<T>().
  • Read methods should not modify data — they return computed values.
  • Methods are automatically exposed via the OData API with no additional configuration.
  • The args object provides strongly-typed access to each method parameter by name.