Build Access-Style C# Form Modules

Access form modules are useful because the code lives next to the form. A button click, a before-save rule, and a small helper method can travel with the database instead of being buried in a separate application host.

CSharpDB code modules bring that idea to Admin Forms with normal C# tooling. The database stores the source. Admin can generate event-handler stubs, export the modules to files, import changes, build them with Roslyn, show diagnostics, and require explicit local trust before anything runs.

Scenario: a warehouse team uses an Order Workbench Admin Form to manage outbound orders. Operators can edit an order, but the business has cross-field rules that are too specific for a generic required-field validator. A high-value expedited order needs manager approval. A hold needs a hold reason. A ship-ready order needs a carrier service and a valid required ship date. A supervisor also wants an Escalate button that marks the order for review and notifies the operations lead through a trusted host command.

This post walks that scenario from setup to execution. The exact field names can be adapted to your own form, but the pattern is the important part: declarative form metadata handles layout, a host command owns external notification, and the form module owns local business rules that should travel with the database.

What Code Modules Add

Before code modules, Admin Forms already had trusted host commands and declarative action sequences. Those are still the right tools for many workflows. Code modules fill the gap where the logic is small, form-specific, and easier to express as C# than as a chain of declarative conditions.

Use Best Tool Why
Set a field, show a message, open another form, apply a filter Action sequence No code, serializes cleanly with the form definition.
Send email, call an API, write to an application service Trusted host command Privileged integrations stay in the application host.
Cross-field validation, button-specific business logic, helper methods C# code module The logic travels with the database and builds like normal C#.

The Workflow

Step 1
Create handlers
Use the form designer to create a C# handler for BeforeUpdate and one for the escalation button's OnClick.
Step 2
Export files
Open the Code Modules tab and export the module workspace to a normal folder.
Step 3
Edit C#
Open the exported .cs file in your editor and add the form-specific rules.
Step 4
Import, build, trust
Import the files, build the module set, review diagnostics, then trust the current hash locally.

Host Setup

The packaged CSharpDB Admin host already wires the code-module services. If you host Admin Forms yourself, register both the code-module runtime and the form designer integration:

using CSharpDB.Admin.Forms.Services;
using CSharpDB.CodeModules;

builder.Services.AddCSharpDbCodeModules(options =>
{
    options.EnableInProcessExecution = true;
});

builder.Services.AddCSharpDbAdminForms();
builder.Services.AddCSharpDbAdminFormCodeModules();

The explicit EnableInProcessExecution flag matters. A database can store code modules without being allowed to execute them. Runtime execution requires host opt-in, a successful build, and a local trust grant for the exact module-set hash.

Register the Host Command

The form module should not know how to send Slack messages, email, Teams notifications, or tickets. That belongs in a trusted host command. The form module can ask for NotifyOpsLead; the host decides what that means. In a self-hosted app, use the command overload below in place of the plain AddCSharpDbAdminForms() call from the setup snippet.

using CSharpDB.Primitives;

builder.Services.AddCSharpDbAdminForms(commands =>
{
    commands.AddAsyncCommand(
        "NotifyOpsLead",
        new DbCommandOptions(
            Description: "Notifies the operations lead that an order needs review.",
            Timeout: TimeSpan.FromSeconds(5)),
        async (context, ct) =>
        {
            string orderNumber = context.Arguments["orderNumber"].AsText;
            string reason = context.Arguments["reason"].AsText;

            await opsNotifier.NotifyAsync(orderNumber, reason, ct);

            return DbCommandResult.Success("Ops lead notified.");
        });
});

In a real application, opsNotifier is your service. It can call an internal API, enqueue a message, or write to an audit table. The database-owned module only sees the safe command surface.

Create the Form Handlers

In Admin, open the Order Workbench form in the designer. Save the form first so it has a stable form id. Then add two event bindings:

  1. At the form level, add BeforeUpdate and click Create in the C# handler row.
  2. Select the escalation button, add OnClick, and click Create in the C# handler row.

The designer creates or updates one form module and attaches CodeModuleHandler references to the event bindings. For a form named Order Workbench, the generated source looks like this:

using CSharpDB.CodeModules.Runtime;

namespace CSharpDB.UserCode.Forms;

public sealed class OrderWorkbenchModule : FormCodeModule
{
    public void OnBeforeUpdate(FormBeforeEventContext context)
    {
    }

    public void EscalateOrderButton_OnClick(FormControlEventContext context)
    {
    }
}

Export to Files

Open the Code Modules tab from the title bar or command palette. Choose a workspace folder and click Export. Admin creates a file workspace under .csharpdb-code:

.csharpdb-code/
  csharpdb.codeproj.json
  forms/
    Order_Workbench.cs
  modules/
  classes/

The manifest records each module id, kind, owner, type name, file path, and source hash. On import, CSharpDB compares the manifest hash, current database hash, and file hash. If both the database and the exported file changed since export, import reports a conflict instead of overwriting one side.

Add the Business Rules

Open the exported form module in your normal C# editor and replace the generated methods with the order workflow. This example assumes the form record includes fields such as status, priority_code, carrier_service, required_ship_date, hold_reason, manager_approval_code, total_amount, notes, order_number, and last_reviewed_utc.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
using CSharpDB.CodeModules.Runtime;

namespace CSharpDB.UserCode.Forms;

public sealed class OrderWorkbenchModule : FormCodeModule
{
    private static readonly HashSet<string> ShipReadyStatuses = new(StringComparer.OrdinalIgnoreCase)
    {
        "ReadyToShip",
        "PartiallyAllocated",
    };

    public void OnBeforeUpdate(FormBeforeEventContext context)
    {
        string status = Text("status");
        string priority = Text("priority_code");
        decimal amount = Money("total_amount");

        if (ShipReadyStatuses.Contains(status) && string.IsNullOrWhiteSpace(Text("carrier_service")))
        {
            context.Cancel("Choose a carrier service before marking this order ready to ship.");
            return;
        }

        DateTime? requiredShipDate = Date("required_ship_date");
        if (status == "ReadyToShip" && requiredShipDate is not null && requiredShipDate.Value.Date < DateTime.Today)
        {
            context.Cancel("Required ship date is in the past. Update the date or put the order on hold.");
            return;
        }

        if (status == "Hold" && string.IsNullOrWhiteSpace(Text("hold_reason")))
        {
            context.Cancel("Hold reason is required when an order is placed on hold.");
            return;
        }

        if (priority == "EXPEDITE" && amount >= 10000m && string.IsNullOrWhiteSpace(Text("manager_approval_code")))
        {
            context.Cancel("High-value expedited orders require a manager approval code.");
            return;
        }

        Me.last_reviewed_utc = DateTime.UtcNow;
    }

    public async Task EscalateOrderButton_OnClick(FormControlEventContext context)
    {
        string reason = context.Arguments.TryGetValue("reason", out object? value)
            ? Convert.ToString(value, CultureInfo.InvariantCulture) ?? "Manual escalation"
            : "Manual escalation";

        Me.priority_code = "EXPEDITE";
        Me.status = "NeedsReview";
        Me.notes = AppendNote(Me.notes, $"Escalated at {DateTime.UtcNow:O}: {reason}");

        var result = await DoCmd.RunHostCommandAsync(
            "NotifyOpsLead",
            new Dictionary<string, object?>
            {
                ["orderId"] = Me["id"],
                ["orderNumber"] = Me["order_number"],
                ["reason"] = reason,
            });

        await DoCmd.ShowMessageAsync(
            result.Succeeded
                ? "Order escalated and the operations lead was notified."
                : result.Message ?? "Order was escalated, but the notification failed.");
    }

    private string Text(string fieldName)
    {
        object? value = Me[fieldName];
        return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
    }

    private decimal Money(string fieldName)
    {
        object? value = Me[fieldName];
        return value is null ? 0m : Convert.ToDecimal(value, CultureInfo.InvariantCulture);
    }

    private DateTime? Date(string fieldName)
    {
        string text = Text(fieldName);
        return DateTime.TryParse(
            text,
            CultureInfo.InvariantCulture,
            DateTimeStyles.AllowWhiteSpaces,
            out DateTime parsed)
                ? parsed.Date
                : null;
    }

    private static string AppendNote(object? existing, string note)
    {
        string current = Convert.ToString(existing, CultureInfo.InvariantCulture) ?? string.Empty;
        return string.IsNullOrWhiteSpace(current)
            ? note
            : $"{current}{Environment.NewLine}{note}";
    }
}

The important detail is Me. It exposes the current form record as dynamic field access. Me.status = "NeedsReview" writes to the current record. Me["order_number"] reads a known field by name. Unknown fields throw so mistakes fail loudly instead of silently writing stray state.

Import, Build, and Trust

Return to Admin and open the Code Modules tab:

  1. Click Import to read the changed files back into the database.
  2. Click Build to compile all modules for the database into an in-memory assembly.
  3. Review diagnostics in the tab. Diagnostics include module/path, line, column, severity, code, and message.
  4. Click Trust after the build succeeds.

Trust is local. It is keyed by normalized database path plus module-set hash and stored outside the database. Any source change produces a new module-set hash, which means you build and trust again before execution resumes.

Try the Workflow

Open the rendered Order Workbench form and test the rules with realistic records:

  • Set status to ReadyToShip without a carrier service. Save should be canceled with a clear message.
  • Set status to Hold without a hold reason. Save should be canceled.
  • Set priority_code to EXPEDITE on a high-value order without approval. Save should be canceled.
  • Click the escalation button. The module should update the priority, status, and notes, then call the trusted host command.

This is the shape that makes code modules useful: the form definition still controls the UI, action sequences still handle simple declarative behavior, the host keeps ownership of external capabilities, and the database carries the small C# module that expresses form-specific business rules.

Where This Is Deliberately Narrow

This first slice is intentionally local and trusted. It does not add an in-browser IDE, a VS Code extension, file watching, debugger integration, report modules, remote execution through daemon transports, or sandboxing. That restraint is part of the safety model: execution is opt-in, build-gated, trust-gated, and limited to the runtime contracts Admin exposes.

For teams coming from Access, the mental model is familiar: form module code sits with the form, event handlers are created from the designer, and helper methods live nearby. The difference is that the edit loop uses normal C# files and the run loop refuses to execute changed code until the current module set is built and trusted locally.