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 : EntityBase
{
    [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

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
});

Static method

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

Scenarios

1. Reordering from a previous sales order

Copy details from a previous order into a new order. This is a modify method because it changes data.

[ApiEntity]
public partial class SalesOrder : EntityBase
{
    [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
                };
            }

            context.SaveChanges();
        });
    }
}

API call:

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

2. Calculating a line total with tax

A read method that computes a value without modifying data.

[ApiEntity]
public partial class SalesOrder : EntityBase
{
    [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

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

[ApiEntity]
public partial class SalesOrder : EntityBase
{
    [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. Updating a sales order's status

A modify method that changes the order status and records timestamps.

[ApiEntity]
public partial class SalesOrder : EntityBase
{
    [Method("Update status", MethodType.Modify)]
    public partial void UpdateStatus(SalesOrderStatus newStatus);
}

//TODO: The following is no longer a good scenario because we don't set the status directly.

[Logic]
public class SalesOrderBL(SalesOrder.Logic salesOrder)
{
    [RegisterLogic]
    public void UpdateStatusMethod()
    {
        salesOrder.Method(e => e.UpdateStatus).Do((order, args) =>
        {
            if (order.Status == args.newStatus) return;

            order.Status = args.newStatus;
            if (args.newStatus == SalesOrderStatus.Closed)
                order.ClosedDate = DateTime.UtcNow;

            args.GetService<IDataContext>().SaveChanges();
        });
    }
}

API call:

POST /api/SalesOrder({orderId})/UpdateStatus?newStatus=Closed

5. Parameterless method

Methods with no parameters are also supported.

[ApiEntity]
public partial class Product : EntityBase
{
    [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.

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.