AngryMonkey.CDM.ExternalClient
8.0.1
dotnet add package AngryMonkey.CDM.ExternalClient --version 8.0.1
NuGet\Install-Package AngryMonkey.CDM.ExternalClient -Version 8.0.1
<PackageReference Include="AngryMonkey.CDM.ExternalClient" Version="8.0.1" />
<PackageVersion Include="AngryMonkey.CDM.ExternalClient" Version="8.0.1" />
<PackageReference Include="AngryMonkey.CDM.ExternalClient" />
paket add AngryMonkey.CDM.ExternalClient --version 8.0.1
#r "nuget: AngryMonkey.CDM.ExternalClient, 8.0.1"
#:package AngryMonkey.CDM.ExternalClient@8.0.1
#addin nuget:?package=AngryMonkey.CDM.ExternalClient&version=8.0.1
#tool nuget:?package=AngryMonkey.CDM.ExternalClient&version=8.0.1
Cloud Data Management (CDM) - Complete Reference
CDM is the AngryMonkey Cloud framework for building data-driven portal applications on top of Azure Cosmos DB and Azure Blob Storage. It provides a code-generation builder, a metadata-driven form/view engine, role-based security, search, audit logging, and a full Blazor UI - all from a single, unified configuration.
Table of Contents
- Architecture Overview
- NuGet Packages
- Configuration -
CDMSettings - Server Setup
- Builder Project
- 5.1 Creating the Builder
- 5.2 Entity Definition -
EntitySet - 5.3 Field Types
- 5.4 Field Base Properties
- 5.5 Field & Class Attributes
- 5.6 Forms
- 5.7 Views
- 5.8 Relationships (One-to-Many)
- 5.9 Option Sets
- 5.10 Dashboard
- 5.11 Security in Builder
- 5.12 Builder Options
- 5.13 Running the Builder
- Custom Domain - Overriding Entity Methods
- Form Action Handler
- Security & Permissions
- Search
- Settings Module
- Engine Advanced Features
- Complete Sample - BluSky Portal
- Common Pitfalls
- External Client -
CDM.ExternalClient - Aspire Integration
1. Architecture Overview
+--------------------------------------------------+
| Blazor Portal UI |
| (generated forms, views, dashboard, settings) |
+------------------------+-------------------------+
|
+------------------------v-------------------------+
| CDM Engine Layer |
| CDMEngineBase <------ Custom Domain |
| (routing, metadata, security, audit, sequences) |
+--------+---------------------------------+--------+
| |
+--------v-----------+ +----------------v-------+
| Azure Cosmos DB | | Azure Blob Storage |
| (data + metadata) | | (files, images, audit) |
+--------------------+ +------------------------+
Typical project layout
MyApp.Portal.Builder/ <- runs once; generates everything below
MyApp.Portal.DataContract/ <- entity model classes
MyApp.Portal.Data.Cosmos/ <- auto-generated Cosmos persistence layer
MyApp.Portal.Engine/ <- auto-generated engine + your overrides
MyApp.Portal.Client/ <- auto-generated HTTP client
MyApp.Portal/ <- ASP.NET Core host + auto-generated API controllers
MyApp.Portal.WebAssembly/ <- Blazor WASM (optional)
Files inside
auto-generated/folders are fully managed by the builder. Never edit them manually - regenerate with the builder instead.
2. NuGet Packages
| Package | Used in |
|---|---|
AngryMonkey.CDM.Models |
The entity/field-type model every other package builds on |
AngryMonkey.CDM.Config |
CDMSettings, Cosmos/Storage/security/audit-log configuration |
AngryMonkey.CDM.Builder |
Builder project only - defines entities, forms, views, security |
AngryMonkey.CDM.Components |
The generated Blazor UI (forms, views, dashboard, settings) |
AngryMonkey.CDM.Server |
Portal host project - AddCDMServer |
AngryMonkey.CDM.Server.WebAssembly |
WebAssembly runtime used by CDM.Server's standalone UI |
AngryMonkey.CDM.Client |
Blazor WASM and other external consumers |
AngryMonkey.CDM.ExternalClient |
Standalone API clients with no Blazor/component dependency - see §14 |
AngryMonkey.CDM.Aspire.Hosting |
AppHost-side: adds a CDM server to a .NET Aspire distributed application - see §15 |
AngryMonkey.CDM.Aspire |
Server-side: binds CDMSettings from what the AppHost wired - see §15 |
3. Configuration - CDMSettings
CDMSettings is the root configuration object. Bind it from appsettings.json under the "CDM" key.
BaseName (optional) - one name drives every database/storage name
BaseName is optional. Leave it unset and CDM behaves exactly as before —
every database/container/table name must be configured individually, falling
back to CDM's original fixed defaults ("storage", "uploads", "metadata", ...)
for anything left unset, with Cosmos.DatabaseName still required.
Set BaseName and every Cosmos database, Blob container, and Table Storage table
name derives from it instead (see
CDMResourceNaming for the exact
rules — it's a single file, so change the convention there if you need a different
one). Setting BaseName to "MyProject" gives you:
| Resource | Derived name |
|---|---|
| Cosmos database | MyProject |
| Blob container | MyProject-storage |
| Uploads container | MyProject-uploads |
| Metadata table | MyProjectmetadata |
| Security table | MyProjectsecurity |
| Lookup table | MyProjectlookups |
| Geography table | MyProjectgeography |
| Sequence table | MyProjectsequences |
| Tracking (change log) table | MyProjectchanges |
| Audit log table | MyProjectlogs |
Use a different BaseName per environment ("MyProject-Dev", "MyProject-Staging",
"MyProject") to get an isolated set of resources per environment from a single
appsettings.{Environment}.json override. For a family of related portals (e.g.
MelonCut Guidelines and MelonCut Design), give each its own BaseName
("MelonCut-Guidelines", "MelonCut-Design") so they never collide.
Any name set explicitly (e.g. Cosmos.DatabaseName, Storage.ContainerName) always
wins over the value derived from BaseName.
appsettings.json
{
"CDM": {
"BaseName": "MyProject",
"Title": "My Portal",
"LoginUrl": "https://login.example.com/",
"Cosmos": {
"ConnectionString": "<cosmos-connection-string>"
},
"Storage": {
"ConnectionString": "<storage-connection-string>",
"UploadsRetentionHours": 24,
"UploadsCleanupIntervalMinutes": 60
},
"Security": {
"IsRoleBased": true,
"IsVertical": false
},
"AuditLog": {
"Enabled": true
},
"Theme": {
"AccentColor": "#0c75c4"
},
"Maps": {
"AzureSubscriptionKey": "<azure-maps-key>",
"ValidateAddresses": true
}
}
}
When BaseName is set, everything under Cosmos/Storage/AuditLog besides
ConnectionString and the upload-retention numbers becomes optional — omit a
name to get the BaseName-derived default, or set it explicitly to override just
that one resource. Without BaseName, Cosmos.DatabaseName must still be set
explicitly, same as always.
Configuration binding in Program.cs
CDMSettings settings = new()
{
Title = "My Portal",
Theme = new() { AccentColor = "#0c75c4" },
AuditLog = new() { Enabled = true }
};
// Define security roles before binding (so code-defined roles are not overwritten)
settings.Security.IsRoleBased = true;
settings.Security.PreconfiguredRoles.Add(new() { Code = "Admin", Name = "Administrator" });
settings.Security.PreconfiguredRoles.Add(new() { Code = "Editor", Name = "Editor" });
// Bind remaining values from appsettings (BaseName, Cosmos, Storage, LoginUrl ...)
builder.Configuration.GetSection("CDM").Bind(settings);
CDMSettings properties
| Property | Type | Description |
|---|---|---|
BaseName |
string? |
Optional base name for all databases/storage resources for this deployment; see above |
Title |
string |
Portal display name |
LoginUrl |
string |
Base URL of the CloudLogin server |
Cosmos |
CDMCosmosSettings |
Cosmos DB connection |
Storage |
CDMStorageSettings |
Azure Storage connection + table names |
Security |
CDMSecuritySettings |
Role-based / vertical access flags |
AuditLog |
CDMAuditLogSettings |
Enable audit log + storage settings |
Theme |
CDMThemeSettings |
UI accent color |
Maps |
CDMMapsSettings |
Azure Maps key + address validation toggle |
4. Server Setup
Program.cs - minimal setup
using CDM;
using AngryMonkey.Cloud.CDM;
using MyApp.Portal;
using MyApp.Portal.DataContract;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
CDMSettings settings = new() { Title = "My Portal" };
settings.Security.IsRoleBased = true;
settings.Security.PreconfiguredRoles.Add(new() { Code = "Admin", Name = "Administrator" });
builder.Configuration.GetSection("CDM").Bind(settings);
// Registers engine, domain, Cosmos client, storage, security, and all CDM middleware
builder.AddCDMServer<MyAppEngine, MyAppCosmos, MyAppDomain>(settings);
var app = builder.Build();
// Mounts CDM middleware, routing, SignalR hub, and static assets
app.UseCDM<MyAppDomain>();
await app.RunAsync();
AddCDMServer signature
builder.AddCDMServer<TEngine, TCosmos, TDomain>(CDMSettings settings, Action<CDMConfig<TDomain>>? configAction = null);
TEngine- your engine class (extends the auto-generatedDefaultXxxEngine)TCosmos- your Cosmos class (auto-generated from builder)TDomain- your domain class (extends the auto-generatedDefaultXxxDomain)
CDMConfig<TDomain> (optional configure action)
| Property | Type | Description |
|---|---|---|
CustomDomain |
TDomain? |
Pre-built domain instance, when you need to construct it yourself instead of letting CDM resolve it from DI |
WebConfig |
Action<CloudWebConfig>? |
Configures the underlying CloudBlazor host (page title, favicon, indexing, bundles, ...) |
Settings |
Action<CDMSettingsBuilder>? |
Registers the Settings module (Security roles, Addresses, custom areas) |
builder.AddCDMServer<MyEngine, MyCosmos, MyDomain>(settings, config =>
{
config.WebConfig = web =>
{
web.PageDefaults.SetTitle("My Portal");
web.PageDefaults.SetFavicon("favicon.svg");
web.PageDefaults.SetIndexPage(false);
web.PageDefaults.SetFollowPage(false);
};
// Register the Settings module (Security roles, Addresses ...)
config.Settings = s => s
.HasRoleBasedAccessSettings(engine.Security.DataStorage)
.HasAddressesSettings(new MyAddressOperations());
});
Titles, favicon, indexing, and CDM's own CSS bundle already get sensible defaults
(see AddCDMServer's implementation in CDM.Server/ServiceExtensions.cs) if
WebConfig is left unset or only sets some of them.
WebAssembly hosting (CDM.Server.WebAssembly)
The optional MyApp.Portal.WebAssembly project hosts the interactive-WebAssembly
render mode. Its whole Program.cs is one call:
// Program.cs of the WebAssembly project
using CDM;
await CDMWebAssembly.RunAsync(args);
CDMWebAssembly.RunAsync builds a WebAssemblyHostBuilder, calls
services.AddCDMServerWebAssembly(baseAddress), and runs the host. That
extension method:
- Creates a
CDMClientpointed at the host's own base address and fetches the module metadata (Module) and the Maps configuration once at startup — the Azure Maps key is never baked into the published WASM assets; it is released only to authenticated sessions. - Registers
AddHttpRoleLoader()as the WebAssemblyIRoleLoader(the server instead usesServerRoleLoader, registered byAddCDMServer). - Calls the same
AddCDMComponents()used by the server, so field renderers,FileUploadCoordinator,CDMAccountService, and the custom field registry behave identically on both render targets. WebAssembly always keeps the defaultIAccessPolicyit registers — it exists for UI hints only; real permission enforcement always happens on the server. - Builds the SignalR
HubConnectionused by the Form Action Handler (/actionHub), serialized withCDMSerialization.Options.
Register any WebAssembly-only custom field components before RunAsync returns
control, from inside your own copy of AddCDMServerWebAssembly or a follow-up
call to services.AddCustomFieldComponent<TComponent>("Key") (see
Fields.Custom).
AddCDMComponents (shared component services)
Everything the CDM Razor components resolve at runtime and that is identical on the
server and in WebAssembly — FileUploadCoordinator, CDMAccountService,
IAccessPolicy, CloudGeographyClient, the custom field registry — is registered by
a single method:
services.AddCDMComponents();
AddCDMServer and AddCDMServerWebAssembly already call it, so a normal portal needs
nothing extra. Call it explicitly only from a host that composes its own container and
renders CDM components without going through those two entry points — this keeps that
host working when new component services are added to CDM.
Every registration uses TryAdd, so anything you register before calling it wins.
Host-specific services are not covered and must still be registered separately:
IRoleLoader (ServerRoleLoader on the server, AddHttpRoleLoader() on WebAssembly),
CDMClient, Module, and the SignalR HubConnection.
CORS (optional)
builder.Services.AddCors(options =>
options.AddPolicy("PortalCors", policy =>
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
// ...
app.UseCors("PortalCors");
app.UseCDM<MyAppDomain>();
5. Builder Project
The builder is a console application that reads your entity definitions and generates the entire Cosmos, Engine, DataContract, Client, and Portal auto-generated code. Run it whenever you change entity structure.
5.1 Creating the Builder
// Program.cs of the Builder project
using AngryMonkey.Cloud.CDM;
using AngryMonkey.Cloud.CDM.Models;
using MyApp.Builder.Entities;
var builder = new CDMBuilder("MyApp.Portal");
// Optional: set module-level defaults
builder.Module.DefaultCountryCode = "US";
// Register entities and collection item sets through the same API.
// Collection item schemas referenced by a parent are also discovered automatically.
builder.Entity<SocialMediaLink>();
builder.Entity<Contact>();
builder.Entity<Property>();
builder.Entity<Region>();
// Dashboard (see section 5.10)
builder.HasDashboard()
.HasDashboardView<Contact>()
.HasDashboardView<Property>();
// Centralized security (see section 5.11)
builder.HasSecurity<MySecurityConfig>();
// Generate
await builder.GenerateAndReplaceFiles(new CDMBuilderOptions
{
CreateMainProjects = false
});
CDMBuilder constructor:
new CDMBuilder(
schemaName: "MyApp.Portal", // used as namespace prefix and folder name
domain: null, // defaults to schemaName
defaultCurrency: "USD" // used by Money fields
)
5.2 Entity Definition - EntitySet
Every entity is a class that inherits EntitySet. Public properties of type Field (or any subclass) become the entity's fields.
using CDM;
using CDM.Builder;
using AngryMonkey.Cloud.CDM.Models;
[Entity("Contact", "Contacts")] // singular and plural display names
public class Contact : EntitySet
{
public Fields.SingleLine FirstName { get; set; } = new()
{
Required = RequiredField.Required
};
// The primary field drives default search and record title display
public Fields.SingleLine Name { get; set; } = new()
{
IsPrimary = true
};
public Fields.SingleLine PhoneNumber { get; set; } = new(Fields.SingleLineValidation.PhoneNumber)
{
Required = RequiredField.Required
};
// Override Forms and Views (sections 5.6 and 5.7)
protected override FormConfig[] Forms => [ /* ... */ ];
protected override ViewConfig[] Views => [ /* ... */ ];
}
If you omit Forms / Views overrides, the builder auto-generates a default form (all fields) and a default view (primary field only).
5.3 Field Types
All field types live in the static Fields class inside AngryMonkey.Cloud.CDM.Models.
Fields.SingleLine
Plain text input with optional built-in validation.
// Plain text
public Fields.SingleLine Name { get; set; } = new();
// With built-in validation
public Fields.SingleLine Email { get; set; } = new(Fields.SingleLineValidation.Email);
public Fields.SingleLine Website { get; set; } = new(Fields.SingleLineValidation.Url);
// Phone number with dial-code selector
public Fields.SingleLine Phone { get; set; } = new(Fields.SingleLineValidation.PhoneNumber)
{
PhoneDefaultCountryCode = "LB" // seeds the country dial-code picker
};
// Custom regex validation
public Fields.SingleLine PostalCode { get; set; } = new(Fields.SingleLineValidation.Regex)
{
ValidationPattern = @"^\d{5}$",
ValidationErrorMessage = "Must be a 5-digit ZIP code"
};
// Autocomplete from an option set or runtime data source
public Fields.SingleLine City { get; set; } = new()
{
Behavior = FieldBehavior.Autocomplete,
AutocompleteCategory = "Cities",
AutocompleteAllowCreate = true
};
SingleLineValidation values: None, Email, PhoneNumber, Url, Regex
Fields.MultipleLines
Multi-line textarea.
public Fields.MultipleLines Notes { get; set; } = new() { FullWidth = true };
Fields.WholeNumber
Integer input.
public Fields.WholeNumber BedroomCount { get; set; } = new() { DefaultValue = 1 };
Fields.DecimalNumber
Decimal number input.
public Fields.DecimalNumber AreaSize { get; set; } = new()
{
Label = "Area Size (m2)",
Required = RequiredField.Required,
Description = "Enter the area in square meters"
};
Fields.Money
Currency amount. Uses the builder's DefaultCurrency.
public Fields.Money AskingPrice { get; set; } = new()
{
Label = "Asking Price",
Description = "Public listing price"
};
Fields.Boolean
Checkbox / toggle.
public Fields.Boolean IsActive { get; set; } = new() { DefaultValue = true };
public Fields.Boolean MainRoad { get; set; } = new();
Fields.Date
Date picker.
public Fields.Date ClosingDate { get; set; } = new();
Fields.Predefined
Dropdown / buttons / autocomplete backed by a named option set (section 5.9).
// Reference a named option set by its schema name
public Fields.Predefined Status { get; set; } = new("ContactStatus")
{
Required = RequiredField.Required,
DefaultValue = "Active", // must match an OptionSetOption.Key
Behavior = FieldBehavior.DropDown // DropDown | Autocomplete | Buttons
};
// Multiple selection
public Fields.Predefined Amenities { get; set; } = new("AmenitiesOptionSet")
{
MultipleValue = true
};
// Buttons rendering
public Fields.Predefined Estimated { get; set; } = new("EstimatedOptionSet")
{
Behavior = FieldBehavior.Buttons,
DefaultValue = "Priced"
};
FieldBehavior values for Predefined: DropDown, Autocomplete, Buttons
Fields.OnDemand
Options loaded at runtime by the Form Action Handler. The option list is not embedded in metadata - it is pushed by calling FormActionResult.ChangeOptions(...) from your action handler.
// Single selection, shown as dropdown
public Fields.OnDemand Broker { get; set; } = new()
{
Behavior = FieldBehavior.DropDown,
MultipleValue = false
};
// Multiple selection, shown as picker
public Fields.OnDemand AssignedUsers { get; set; } = new()
{
Behavior = FieldBehavior.Picker,
MultipleValue = true,
AllowCreate = false
};
FieldBehavior values for OnDemand: DropDown, Autocomplete, Picker, Buttons
Fields.EntityReference
Look-up to another entity record. Renders as a picker with an optional inline "New" form.
// Reference a custom entity
public Fields.EntityReference CoverImage { get; set; } = new("Media");
// Reference a system entity
public Fields.EntityReference Owner { get; set; } = new(SystemEntityReference.User);
// Declarative filters on the picker
public Fields.EntityReference City { get; set; } = new("City")
{
Behavior = FieldBehavior.Picker,
AllowNew = true,
DisplayFieldSchemaName = "Name",
Filters =
{
new() { FieldSchemaName = "IsActive", Value = true },
new() { FieldSchemaName = "Country", Value = "00000000-0000-0000-0000-000000000001" }
}
};
// Multiple references
public Fields.EntityReference Tags { get; set; } = new("Tag")
{
MultipleValue = true,
AllowNew = false
};
FieldBehavior values: Picker (default), DropDown, Autocomplete, Buttons
SystemEntityReference values: Custom, User, Team
EntityReferenceFilterOperator values: Equals, NotEquals, GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual
Fields.Address
Structured address with per-sub-field control. Supports both single and multiple addresses (MultipleValue = true).
Two input modes (InputMode):
Structured(default) — a details popup with one input per enabled part.Lookup— a single text input with autocomplete over the geography data (cities from the Table Storagecitycategory, plus district- and subdivision-level entries generated from CloudGeography when the country is locked).AllowNewEntriescontrols whether unknown values can be added through the details popup (false= select-only from the settings-managed list).
The hierarchy is Country → Subdivision → SubdivisionChild (district) → City.
IsRequired on those three levels defines the minimum selection depth, not
independent per-part requiredness: a required City rejects district-only
values; an optional City with a required SubdivisionChild accepts district-level
values; and so on. Adding a new city always requires its full enabled
ancestor chain regardless of the minimum depth.
Per-part Label overrides the built-in input label, and ShowInDisplay = false
hides a part from the formatted display while keeping it stored and searchable
(the deepest selected level is always shown).
public Fields.Address Address { get; set; } = new()
{
Required = RequiredField.Required,
Country = new()
{
IsEnabled = false, // user cannot change the country
IsVisible = false,
IsRequired = true,
DefaultValue = "LB" // ISO-3166-1 alpha-2 code
},
Subdivision = new()
{
IsRequired = true,
Codes = ["BA", "JL", "AS"] // restrict to specific subdivisions
},
SubdivisionChild = new() { IsRequired = true },
City = new() { IsRequired = true },
Line1 = new() { IsRequired = false },
Line2 = new() { IsVisible = false },
PostalCode = new() { IsVisible = false },
Map = new() { IsRequired = false }
};
// Multiple address values (e.g. "preferred cities")
public Fields.Address PreferredCities { get; set; } = new()
{
Label = "Cities",
MultipleValue = true,
Country = new() { IsEnabled = false, IsVisible = false, DefaultValue = "LB" },
Subdivision = new() { IsRequired = true },
City = new() { IsRequired = true }
};
AddressFieldOption properties: IsEnabled, IsVisible, IsRequired, DefaultValue, Codes, Label, ShowInDisplay
Fields.Address properties: InputMode (Structured | Lookup), AllowNewEntries
Outside CDM forms, the same configuration drives the standalone Blazor
AddressPicker component (CDM.Components.Fields):
<AddressPicker Options="myFieldsAddress" @bind-Value="model.Address" />
Fields.Schedule
Weekly schedule grid (day-of-week rows with start/end times).
public Fields.Schedule WorkingHours { get; set; } = new()
{
DayOfWeek = new() { IsRequired = true },
StartTime = new() { IsRequired = true },
EndTime = new() { IsRequired = true },
IsActive = new() { IsVisible = true },
Notes = new() { IsVisible = false },
AllowMultipleTimeSlotsPerDay = true,
Show24HourFormat = true
};
Fields.Collection<TItem>
An ordered collection of structured items stored directly inside its parent record. Collection items have reusable CDM field metadata and typed generated contracts, but no independent table/container, discriminator, CRUD endpoint, search result, audit record, or relationship. Permissions inherit from the parent by default and can optionally be overridden for the item.
First define the collection item schema:
[CollectionItem("Social Media Link", "Social Media Links")]
public class SocialMediaLink : CollectionItemSet
{
public Fields.SingleLine Title { get; set; } = new()
{
IsPrimary = true,
Required = RequiredField.Required
};
public Fields.SingleLine Link { get; set; } = new()
{
Required = RequiredField.Required,
Validation = Fields.SingleLineValidation.Url
};
public Fields.Boolean Hidden { get; set; } = new()
{
DefaultValue = false
};
}
builder.Entity<Business>();
builder.Entity<SocialMediaLink>(); // Optional; auto-detected from Business too.
The builder discovers SocialMediaLink automatically from the parent's
Fields.Collection<TItem> field. If you want to register or configure it explicitly, use
the same Entity<T>() API; the builder detects CollectionItemSet and emits
parent-owned collection-item metadata instead of independent storage or CRUD:
builder.Entity<SocialMediaLink>()
.HasPermission("ContentEditor", EntityPermissions.All)
.HasPermission("Viewer", EntityPermissions.Read);
Once a collection-item permission is explicitly configured, roles without a matching rule have no access to that item. An item edit grant permits a save restricted to that parent collection field; it does not grant update or delete permission on the parent record.
Then add the parent field:
public Fields.Collection<SocialMediaLink> SocialMediaLinks { get; set; } =
new()
{
AllowReordering = true,
AllowDuplicates = true,
MinimumItems = 0,
MaximumItems = 20
};
File-backed child schemas can opt into a gallery summary. Configure the child entity once; the same presentation is used when it is stored in a collection or displayed through a one-to-many relationship:
public class MediaAsset : CollectionItemSet
{
protected override EntityCollectionOptions CollectionOptions => new()
{
Type = CollectionType.ImagesAndVideos,
PrimaryFileFieldSchemaName = nameof(Source),
PrimaryTitleFieldSchemaName = nameof(Title),
FileOptions = new FileFieldOptions
{
// Optional: MIME category and extension restrictions are combined.
AcceptedExtensions = ["jpg", "jpeg", "png", "webp", "mp4", "mov"]
}
};
public Fields.File Source { get; set; } = new();
public Fields.SingleLine Title { get; set; } = new() { IsPrimary = true };
}
public Fields.Collection<MediaAsset> Media { get; set; } = new();
Supported collection types are Files, Documents, Images, Videos,
ImagesAndVideos, Colors, and Fonts. Each file-backed type supplies its
MIME restriction automatically; FileOptions.AcceptedExtensions can narrow it
further. Fonts accepts WOFF, WOFF2, TTF, and OTF files. Colors uses
PrimaryColorFieldSchemaName, which must point to a Fields.Color; its gallery
card renders either the configured solid color or CSS gradient. Collections
without a usable preview render No preview available.
Fields.Color persists one string value and provides a reusable color studio
next to the text input. The CloudComponents popup supports solid colors,
multi-stop linear/radial/conic gradients, angle and stop controls, and advanced
CSS values such as oklch(...), color-mix(...), CSS variables, or repeating
gradients. Validation rejects URLs, declarations, and unsafe or malformed CSS.
The collection options are applied to every file field on the child entity. If
the primary field names are omitted, the builder uses the first file field and
the field marked IsPrimary. A normal EntitySet can override the same
CollectionOptions; when it is the many side of a one-to-many relationship,
the form renders the same gallery while each child remains an independently
saved CDM record.
Collection-item form fields can be shown conditionally:
new FormFieldConfig(nameof(Source))
{
IsVisibleByDefault = false,
VisibleWhenFieldSchemaName = nameof(Type),
VisibleWhenValues = ["Image", "Video", "Document"]
}
The editor reevaluates the condition when the controlling field changes. Hidden fields are excluded from required-field validation, so a required file does not block saving an collection item type that does not use a file.
Generated contracts for these entities expose a derived FileType property
with a MediaFileType value (Image, Video, Audio, Document, File, or
Unknown). It is inferred from the configured primary file's MIME type and
extension, is available to API and library consumers, and is ignored by the
Cosmos/Newtonsoft storage contract. Runtime-only IsImage, IsVideo,
IsAudio, and IsDocument properties are generated for convenience and are
also excluded from storage. Legacy Boolean fields with those names on a
file-backed builder entity are ignored, so they can no longer become persisted
Cosmos properties.
The value is saved atomically as an ordered JSON array on the business record.
Each item has an internal key used to identify it during editing and
reordering; that key is not an independent CDM record ID.
Changes inside an collection item invoke the parent form action handler. The
handler receives the complete collection through
request.Output.GetCollectionItems<SocialMediaLink>() and scoped information
about the triggering item:
IReadOnlyList<SocialMediaLink> links =
request.Output.GetCollectionItems<SocialMediaLink>(nameof(Business.SocialMediaLinks));
CollectionItemActionContext? change = request.CollectionItemContext;
// change.CollectionFieldSchemaName
// change.CollectionItemSchemaName
// change.ItemKey
// change.ChangedFieldSchemaName
// change.ChangeType
Collection item schemas support persisted value fields—including files—and their normal validation, defaults, and form metadata. Entity references, custom sections, and relationship subgrids are rejected by the builder. Collection-item file fields are singular; represent multiple files as multiple collection items.
Nested collections
A Collection<TItem> field is allowed on a CollectionItemSet, so a collection
item can itself contain child collections of the same or a different item
schema (e.g. AssetGroups → Sections → Assets). Each nesting level is an
ordinary Fields.Collection<TItem> property with its own MinimumItems /
MaximumItems / AllowReordering, and the parent form still renders the whole
tree through the same collection editor.
Moving items between collections
When a collection item schema appears as the item type of more than one
Fields.Collection<TItem> field in the same record (typically at different
levels of a nested hierarchy), the collection editor's selection toolbar shows
a Move button next to Delete. Moving:
- Lists every other collection field in the record whose item schema matches
the source collection's, labeled with its ancestor items' titles
(
Group A › Section 1), so the destination is always unambiguous even when the containing branch is collapsed or not currently open in the editor. - Refuses destinations that would create a cycle (a collection nested inside
the item(s) being moved) and destinations already at
MaximumItemscapacity. - Preserves each item's
id— a move changes an item's parent collection, not its identity, and file blobs already attached to the item are carried over rather than re-uploaded or deleted. The generated Cosmos save path indexes every incoming and existing item by id across the whole record for exactly this reason, instead of only diffing each collection against its own previous sibling list. - Reaches the parent form action handler the same way any other item change
does:
request.CollectionItemContext.ChangeTypeisCollectionItemChangeType.Movedfor the moved item(s).
A Boolean inside an collection item schema can limit how many items may be selected. The form keeps the newly selected item and clears older selections that exceed the limit, while server-side validation rejects invalid API payloads:
public Fields.Boolean IsCoverImage { get; set; } = new()
{
DefaultValue = false,
MaximumTrueItems = 1
};
Fields.File
File upload backed by Azure Blob Storage. Specialize it for images, videos,
PDFs, or selected extensions through FileOptions instead of using a separate
image field type.
public Fields.File Logo { get; set; } = new()
{
FileOptions = new()
{
AcceptedContentTypes = ["image/*"],
AcceptedExtensions = [".jpg", ".jpeg", ".png", ".webp"],
MaximumFileSize = 10 * 1024 * 1024
}
};
Direct browser uploads are placed in a private uploads container and promoted
to the field's configured final storage path only when the record is saved. The
server never proxies successfully direct-uploaded file bytes. Unattached blobs
are removed by the server's cleanup worker after UploadsRetentionHours
(24 hours by default). If UploadsContainerName is omitted, CDM uses
{BaseName}-uploads (see BaseName).
Fields.TextEditor
Rich text field. Every toolbar feature is enabled by default; restrict what
the field can do with the fluent configuration or by setting Toolbar
directly. Toggles are per-feature, so "media" (image + video) and "color"
(text color + highlight) can each be turned off as a group, or item by item.
public Fields.TextEditor Description { get; set; } = new(config => config
.NoColor() // disables text color + highlight color
.NoMedia()); // disables image + video insertion
public Fields.TextEditor Summary { get; set; } = new(config => config
.Images(false) // no images
.Videos() // videos still allowed
.CodeView(false)); // no HTML source view
Fields.Custom
Embeds a developer-supplied Razor component inside the form. Data is pushed from the Form Action Handler via UpdateCustomData.
public Fields.Custom AreasMap { get; set; } = new() { ComponentKey = "RegionMap" };
Register the component in your Blazor project (Program.cs, before the app
builds) with AddCustomFieldComponent<TComponent>(key). It is safe to call once
per component type; every call shares the same singleton ICustomFieldComponentRegistry:
builder.Services.AddCustomFieldComponent<RegionMapField>("RegionMap");
The component inherits CustomFormFieldBase and receives a Context
(CustomFormFieldContext) on every render:
@* RegionMapField.razor *@
@inherits CustomFormFieldBase
<div>@Context.Data?.GetRawText()</div>
@code {
private MyMapData? Data => GetData<MyMapData>();
private Task OnPointSelected(double lat, double lng) =>
ChangeValueAsync(nameof(Region.Areas), new { Lat = lat, Lng = lng });
}
| Member | Description |
|---|---|
Context.Data |
The last JsonElement pushed via result.UpdateCustomData(field, data); GetData<T>() deserializes it |
ChangeValueAsync(schemaName, value) |
Sets another field's value; counts as a user edit |
SetBaselineAsync(schemaName, value) |
Sets what the form considers the field's starting value, without counting as an edit |
RequestActionAsync(invoker) |
Re-invokes the form action handler (OnInput by default) |
AddCustomFieldComponent must be registered on every host that renders the
form — server and WebAssembly both need their own call, since each keeps its own
DI container and therefore its own registry instance.
Fields.OneToManySubgrid
Used internally by the form renderer for one-to-many sub-grids. Not declared on the entity class - added automatically via the [OneToMany] attribute or FormConfig.Relationships.
5.4 Field Base Properties
These properties are available on every field type.
| Property | Type | Default | Description |
|---|---|---|---|
SchemaName |
string |
(property name) | Internal identifier; auto-set from the C# property name |
Label |
string |
(auto from property name) | Display label; auto-derived from PascalCase name |
Description |
string? |
null |
Help text shown beneath the field |
Required |
RequiredField? |
None |
None / Required (shows *) / Recommended (shows +) |
IsVisible |
bool |
true |
Initial visibility on the form |
Editable |
bool |
true |
Whether the field can be edited |
IsPrimary |
bool |
false |
One field per entity should be primary; drives default search and record title |
DefaultValue |
object? |
null |
Pre-populated value for new records |
FullWidth |
bool |
false |
Spans the full form width on every form this field appears in. For per-form overrides use FormFieldConfig.ColSpan = 0 instead. |
IsUnique |
bool |
false |
Enforces uniqueness at save time |
IsSequential |
bool |
false |
Auto-incremented integer (uses CDM Sequence service) |
MultipleValue |
bool |
false |
Allow multiple values (Address, Predefined, EntityReference, OnDemand) |
LockingMode |
LockingModeField? |
Disabled |
Controls field-level locking behavior |
IsLocked |
bool |
false |
Starts locked when true |
FilesDirectoryPrefix |
string? |
null |
Final storage path prefix for File fields |
Permissions |
List<FieldPermissionRule> |
[] |
Field-level security rules (section 8) |
IsSearchable |
bool |
IsPrimary |
Participates in keyword search |
SearchBoost |
double |
1.0 |
Ranking weight during search |
IsFilterable |
bool |
false |
Available in WHERE filters |
IsSortable |
bool |
false |
Available in ORDER BY |
IsDefaultSort |
bool |
false |
Used when no sort is provided |
DefaultSortDirection |
SearchSortDirection |
Ascending |
Default sort direction |
IsFacetable |
bool |
false |
Available as an aggregation facet |
IncludeInGlobalSearch |
bool |
false |
Included in cross-entity search snippets |
IsGlobalSearchTitle |
bool |
false |
Used as the title of the global search hit |
VectorDimensions |
int? |
null |
Azure AI Search vector field size |
VectorProfile |
string? |
"default" |
Azure AI Search vector profile name |
5.5 Field & Class Attributes
Attributes provide a declarative alternative to property initializers. They are read automatically by EntitySet.GetEntity().
Class-level attributes
// Custom singular/plural display names (defaults to class name + "s")
[Entity("Product", "Products")]
// Opt entity out of cross-entity (global) search
[GlobalSearch(false)]
// Enable drag-to-reorder in views; adds a hidden DisplayOrder field automatically
[AllowReordering]
[AllowReordering("SortOrder")] // custom order field schema name
The same behavior can be enabled when registering the entity in the fluent builder:
builder.Entity<Product>(allowReordering: true);
// Or use a custom persisted order field name:
builder.Entity<Category>()
.AllowReordering("SortOrder");
This adds a hidden sequential whole-number field, makes it the default sort for every view, and enables the grid's drag-to-reorder controls.
Property-level search / filter / sort attributes
// Searchable - included in keyword search
[Searchable]
[Searchable(Boost = 2.0, IncludeInGlobalSearch = true)]
public Fields.SingleLine Name { get; set; } = new() { IsPrimary = true };
// Filterable - can be used in WHERE conditions
[Filterable]
public Fields.Predefined Status { get; set; } = new("StatusOptionSet");
// Sortable - can be used in ORDER BY
[Sortable]
[Sortable(IsDefault = true, DefaultDirection = SearchSortDirection.Ascending)]
public Fields.Date CreatedOn { get; set; } = new();
// Facetable - available as an aggregation facet in the UI
[Facetable]
public Fields.Predefined Category { get; set; } = new("CategoryOptionSet");
// Global search field - contributed to cross-entity search snippets
[GlobalSearchField]
[GlobalSearchField(IsTitle = true)] // used as the hit title
public Fields.SingleLine Title { get; set; } = new() { IsPrimary = true };
// Vector field (Azure AI Search)
[VectorField(1536)]
[VectorField(1536, Profile = "my-vector-profile")]
public Fields.SingleLine EmbeddingVector { get; set; } = new() { IsVisible = false };
Relationship attributes (section 5.8)
// Parent side - "Contact has many Deals"
[OneToMany("Contact")] // "Contact" = name of the ManyToOne property on the child
[OneToMany("Contact", "Deals")] // optional label override
public Deal[] Deals { get; set; } = [];
// Child side - "Deal belongs to a Contact"
[ManyToOne]
[ManyToOne("ContactId")] // optional key override
[ManyToOne(label: "Contact")]
public Fields.EntityReference Contact { get; set; } = new("Contact");
5.6 Forms
A form describes which fields appear when a user opens a record. Override Forms in your EntitySet to define one or more named forms.
protected override FormConfig[] Forms =>
[
new FormConfig
{
SchemaName = "General", // used to reference this form by name
Name = "General", // display name (defaults to SchemaName)
// Grid column count: 1, 2 (default), or 3.
// Collapses to 2 columns on tablet and 1 column on mobile automatically.
Columns = 2,
// Top-level fields (placed before any section)
Fields = [ nameof(Status) ],
// One-to-many sub-grids (placed before sections)
Relationships = [ "ContactDeals" ],
Sections =
[
new FormSectionConfig("Personal Info")
{
// Implicit string -> FormFieldConfig conversion
Fields =
[
nameof(FirstName),
nameof(LastName),
nameof(PhoneNumber),
],
},
// Collapsed by default
new FormSectionConfig("Additional", IsCollapsedByDefault: true)
{
Fields = [ nameof(Notes), nameof(Tags) ],
},
// Hidden by default - shown by form action / business rule
new FormSectionConfig("SellerInfo")
{
IsVisibleByDefault = false,
Fields =
[
nameof(SellerAddress),
new FormFieldConfig { SchemaName = nameof(AskingPrice), IsVisibleByDefault = false },
],
Relationships = [ "ContactProperties" ],
},
],
},
// Second named form for a different role
new FormConfig
{
SchemaName = "ReadOnlyForm",
Sections =
[
new FormSectionConfig("Summary")
{
Fields = [ nameof(Name), nameof(Status) ],
},
],
},
];
FormConfig properties
| Property | Type | Default | Description |
|---|---|---|---|
SchemaName |
string |
"General" |
Identifier used to reference this form (e.g. for permissions) |
Name |
string? |
SchemaName |
Display name shown in the UI |
Columns |
int |
2 |
Grid column count: 1, 2, or 3. Auto-collapses to 2 on tablet, 1 on mobile |
Fields |
FormFieldConfig[] |
[] |
Top-level fields placed before any section |
Relationships |
string[] |
[] |
One-to-many sub-grid schema names placed before sections |
Sections |
FormSectionConfig[] |
[] |
Named sections that group fields |
Field layout — FormFieldConfig
FormFieldConfig controls how an individual field is placed in the form grid. All fields in both top-level Fields and section Fields accept this type; plain nameof(...) strings convert implicitly.
Every field's width and height are assigned automatically at build time based on its type — you only set ColSpan / RowSpan when you want to override that default.
// Simple — just the field name (implicit conversion from string).
// The field's width and height are chosen automatically from its type.
nameof(FirstName)
// Explicit — override the auto-assigned layout
new FormFieldConfig
{
SchemaName = nameof(Notes),
IsVisibleByDefault = true, // false = hidden until shown by action handler
ColSpan = 0, // 0 = full width (all columns)
RowSpan = 4, // 4 standard field rows tall
}
FormFieldConfig properties:
| Property | Type | Default | Description |
|---|---|---|---|
SchemaName |
string |
(required) | Field schema name (use nameof(...)) |
IsVisibleByDefault |
bool |
true |
false = hidden until revealed by a form action. A required field that is hidden does not block the form from saving. |
ColSpan |
int? |
auto (by type) | Column span override. 0 = full width (all columns). 1/2/3 = explicit span (clamped to the form's column count). Leave unset to use the type default. |
RowSpan |
int? |
auto (by type) | Height override, measured in standard field rows. Leave unset to use the type default. |
Auto-assigned defaults by field type
When you don't set ColSpan / RowSpan, the builder stamps these defaults onto the field at build time (so the sizing lives in code, not CSS). ColSpan 0 = full width; RowSpan is a count of standard field rows.
| Field type | ColSpan | RowSpan |
|---|---|---|
| Single line, number, money, boolean, date, dropdown, entity reference | 1 | 1 |
| File | 1 | 2 |
| Multi-line text | 1 | 3 |
| Multi-value fields (chips) | 1 | 2 |
| Address | full width | 3 |
| Schedule | full width | 3 |
| Text editor (rich text) | full width | 5 |
| One-to-many / many-to-many sub-grid | full width | 3 |
| Custom component | full width | 3 |
ColSpan = 0vsField.FullWidth: settingColSpan = 0on aFormFieldConfigmakes the field span all columns in that specific form.Field.FullWidth = true(on the field definition itself) makes the field full-width on every form it appears in. Both emit the same_fullWidthCSS class and can be used together or independently.
FormSectionConfig constructor:
new FormSectionConfig(
name: "Section Display Name",
schemaName: "SectionSchemaName", // optional; defaults to name without spaces
IsCollapsedByDefault: false // optional; default false
)
{
IsVisibleByDefault = true, // false = hidden until shown by action handler
Fields = [ ... ], // FormFieldConfig[] or implicit string[]
Relationships = [ "RelName" ], // one-to-many sub-grid schema names
}
Hiding required fields
When a field is hidden (via IsVisibleByDefault = false or a HideField action), CDM automatically skips its required-field validation. The form can be saved without providing a value for that field, even if the field is marked Required. The validation check resumes as soon as the field is made visible again.
Responsive column behaviour
| Screen | Columns |
|---|---|
| Desktop (> 1023 px) | As configured (1, 2, or 3) |
| Tablet (641–1023 px) | Max 2 (a 3-column form becomes 2) |
| Mobile (≤ 640 px) | Always 1 |
Fields with ColSpan = 0 (full-width) or Field.FullWidth = true always span all columns at every breakpoint.
5.7 Views
A view describes the columns shown in the record list grid. Override Views in your EntitySet.
protected override ViewConfig[] Views =>
[
new ViewConfig
{
SchemaName = "General",
Name = "All Contacts",
Fields =
[
nameof(Name),
nameof(PhoneNumber),
nameof(Status),
nameof(CreatedOn),
],
// Optional row-level action buttons
RowButtons =
[
new RowButtonModel
{
SchemaName = "MarkAnswered",
Text = "Answer",
Tooltip = "Mark as Answered",
// Conditionally visible based on a field value
VisibleFieldSchemaName = nameof(Status),
VisibleWhenFieldValueIn = ["ToBeContacted", "NotAnswered"]
},
new RowButtonModel
{
SchemaName = "MarkNoAnswer",
Text = "No Answer",
VisibleFieldSchemaName = nameof(Status),
VisibleWhenFieldValueIn = ["ToBeContacted", "Answered"]
}
],
},
];
RowButtonModel properties:
| Property | Description |
|---|---|
SchemaName |
Identifier passed to the action handler's Run method |
Text |
Button label |
Tooltip |
Hover tooltip |
VisibleFieldSchemaName |
Field whose value controls visibility |
VisibleWhenFieldValueIn |
Array of option keys; button shows when field matches any |
5.8 Relationships (One-to-Many)
Declare one-to-many relationships using [OneToMany] on the parent entity and [ManyToOne] on the child entity property.
// Parent - Property entity
[Entity("Property", "Properties")]
public class Property : EntitySet
{
public Fields.SingleLine Title { get; set; } = new() { IsPrimary = true };
// Declares that Property has many Media records
[OneToMany("Property")] // "Property" = name of the ManyToOne property on Media
public Media[] Media { get; set; } = [];
}
// Child - Media entity
[Entity("Media", "Media Items")]
public class Media : EntitySet
{
public Fields.SingleLine FileName { get; set; } = new() { IsPrimary = true };
public Fields.File File { get; set; } = new();
// Back-reference to parent
[ManyToOne]
public Fields.EntityReference Property { get; set; } = new("Property");
}
To include the sub-grid on a form, reference the relationship schema name (auto-generated as "{ToSchemaName}{FromSchemaName}"):
new FormSectionConfig("Media & Marketing")
{
Fields = [ nameof(CoverImage) ],
Relationships = [ $"Property{nameof(Media)}" ], // "PropertyMedia"
},
For relationships configured through the fluent builder, prefer the strongly typed overloads. They derive entity, field, and relationship schema names from the selected properties, and the same selectors can add the sub-grid to a form:
builder
.HasOneToManyRelationship<Property, Media>(
property => property.Media,
media => media.Property)
.IsRequired()
.HasLabels("Media items", "Property");
builder.GetEntity(nameof(Property))
.GetForm("General")
.HasOneToMany<Property, Media>(
property => property.Media,
media => media.Property);
All relationship entry points share one case-insensitive canonical definition per schema name. Repeated compatible declarations are merged, while conflicting definitions fail early with the relationship name and conflicting metadata in the exception.
5.9 Option Sets
Option sets provide the choices for Fields.Predefined. Define them as classes inheriting BaseOptionSet.
using CDM.Builder.OptionSets;
using AngryMonkey.Cloud.CDM.Models;
[OptionSet(name: "Communication Status")] // optional; defaults to class name
public class CommunicationStatus : BaseOptionSet
{
public override OptionSetOption[] Options =>
[
new() { Key = "ToBeContacted", Value = "To Be Contacted" },
new() { Key = "Answered", Value = "Answered" },
new() { Key = "NotAnswered", Value = "Not Answered" },
new() { Key = "CallReceived", Value = "Call Received" },
];
}
Reference the option set in a field using its schema name (class name by default, or the value from [OptionSet(schemaName: "...")]):
public Fields.Predefined CommunicationStatus { get; set; } = new("CommunicationStatus")
{
DefaultValue = "ToBeContacted"
};
Register all option sets with the builder:
builder.OptionSet<CommunicationStatus>();
builder.OptionSet<LeadStatus>();
builder.OptionSet<PropertyTypes>();
CDMEnum - strongly-typed option sets
For strongly-typed enum-like option sets, derive from CDMEnum:
[JsonConverter(typeof(CDMEnumConverter<PropertyStatus>))]
public class PropertyStatus(string key, string title) : CDMEnum(key, title)
{
public static readonly PropertyStatus Incomplete = new("Incomplete", "Incomplete");
public static readonly PropertyStatus Active = new("Active", "Active");
public static readonly PropertyStatus Sold = new("Sold", "Sold");
public static readonly PropertyStatus Rented = new("Rented", "Rented");
}
5.10 Dashboard
The dashboard shows a configurable set of entity views as navigation items.
builder.HasDashboard()
.HasDashboardView<Contact>() // uses entity's default view
.HasDashboardView<Property>()
.HasDashboardView<Deal>()
.HasDashboardView<Region>()
// Named view overload:
.HasDashboardView("Property", "ActiveProperties");
5.11 Security in Builder
Security can be configured inline on each entity/form/view, or centralized in a RoleBasedSecurityConfig class.
Inline - on builder.Entity<T>()
builder.Entity<Contact>()
// Entity-level: which CRUD operations the role may perform
.HasPermission("Admin", EntityPermissions.All)
.HasPermission("CallCenter", EntityPermissions.Read | EntityPermissions.Update)
.HasPermission("Telemarketer", EntityPermissions.Read,
readScope: AccessScope.Global, writeScope: AccessScope.User)
// Form-level: which role may open and/or edit this form
.GetForm()
.HasPermission("CallCenter", canRead: true, canEdit: true)
.HasPermission("Telemarketer", canRead: true, canEdit: false)
.Entity
// View-level: which role may see this view
.GetView()
.HasPermission("CallCenter")
.HasPermission("Telemarketer")
.Entity
// Named form / view
.GetForm("BrokerForm")
.HasPermission("Broker")
.Entity
.GetForm("MediaForm")
.HasPermission("Media");
Centralized RoleBasedSecurityConfig
public class MySecurityConfig : RoleBasedSecurityConfig
{
public override void Configure()
{
Entity("Contact")
.HasPermission("Admin", EntityPermissions.All)
.HasPermission("CallCenter", EntityPermissions.Read | EntityPermissions.Update)
.GetForm()
.HasPermission("CallCenter", canRead: true, canEdit: true)
.Entity
.GetView()
.HasPermission("CallCenter");
Entity("Property")
.HasPermission("Broker", EntityPermissions.All,
readScope: AccessScope.Global, writeScope: AccessScope.User)
.GetForm("BrokerForm")
.HasPermission("Broker")
.Entity
.GetView()
.HasPermission("Broker");
}
}
// Register in builder
builder.HasSecurity<MySecurityConfig>();
Field-level permissions via builder
builder.Entity<Contact>()
.GetField("SalaryField")
.HasPermission("Admin", FieldPermissions.All)
.HasPermission("Broker", FieldPermissions.Read);
Or via attribute:
// In the EntitySet class - using fluent builder approach in CDMBuilderField
// field.HasPermission(roleCode, FieldPermissions.Read | FieldPermissions.Edit)
5.12 Builder Options
await builder.GenerateAndReplaceFiles(new CDMBuilderOptions
{
// Create brand-new project scaffolding (use once on new projects)
CreateMainProjects = false,
// Path to the local CDM source (for development builds using project references)
LocalReferenceBasePath = @"..\..\..\CDM",
// NuGet package version written into generated .csproj files
PackageVersion = "7.0.0",
// Target framework written into generated .csproj files
TargetFramework = "net10.0",
// Subdirectory relative to the builder project's parent
BaseDirectory = null,
// Overwrite existing non-generated files
OverwriteFiles = false,
// Skip re-creating already-existing project files
SkipExistingProjects = true,
// Create missing directories automatically
CreateDirectoryStructure = true,
// Throw on generation errors (false = log and continue)
ThrowOnError = true,
// Custom domain class name (defaults to "{SchemaName}Domain")
CustomDomain = null,
// BuilderOnly | FullProject | ExceptBuilder
GenerationMode = CDMProjectGenerationMode.ExceptBuilder,
});
5.13 Running the Builder
# From the solution root
dotnet run --project MyApp.Portal.Builder
Run the builder whenever you:
- Add or remove an entity
- Add, remove, or rename a field
- Change a form or view definition
- Add or modify an option set
- Change relationship declarations
- Update security rules
- Upgrade CDM to a version that changes a generated contract property type
A stale generated contract shows up at runtime as a deserialization error naming the
offending property, for example Failed to deserialize RecordJson to type Brand. The JSON value could not be converted to System.String. Path: $.LogoFileName.
Re-run the builder.
The builder only rewrites files whose content has changed and deletes obsolete files from auto-generated/ folders.
Before changing any files, the builder validates the complete configuration and displays a short preflight summary containing only the output location and the source-code/project-generation decisions. If validation fails, every detected error is shown together; press Enter to close the builder immediately. If validation succeeds, review any existing-project overwrite or skip warnings, then press Enter to proceed or Escape to close immediately without changing files.
6. Custom Domain - Overriding Entity Methods
The builder generates a DefaultXxxDomain base class that wires all entities to their auto-generated methods. Override specific entities to inject custom business logic.
// Domain.cs (hand-written file - NOT in auto-generated/)
using AngryMonkey.Cloud.CDM;
namespace MyApp.Portal.DataContract;
public class MyAppDomain(MyAppEngine engine, CloudGeographyClient cloudGeography)
: DefaultMyAppDomain(engine)
{
// Override only the entities you need to customize
public override IRecordMethods<Contact> Contact => new ContactMethods(this, cloudGeography);
public override IRecordMethods<Property> Property => new PropertyMethods(this);
public override IRecordMethods<Deal> Deal => new DealMethods(this);
}
Custom methods class
// ContactMethods.cs (hand-written file in Engine project)
using AngryMonkey.Cloud.CDM;
using AngryMonkey.Cloud.CDM.Common;
public class ContactMethods(MyAppDomain domain, CloudGeographyClient geography)
: DefaultContactMethods(domain) // generated base provides default Save/Get/Delete/...
{
// Override Save to add business logic
public override async Task<Contact> Save(Contact record, RecordOptions.Save options)
{
// Compute derived field before saving
record.Name = $"{record.FirstName} {record.LastName}".Trim();
// Validate uniqueness
var existing = await GetMany(new RecordOptions.GetMany
{
Filters = [new() { FieldSchemaName = nameof(Contact.PhoneNumber), Value = record.PhoneNumber }]
});
if (existing.Records.Any(r => r.ID != record.ID))
throw new InvalidOperationException("A contact with this phone number already exists.");
return await base.Save(record, options);
}
// Override Delete to cascade
public override async Task Delete(Guid id, RecordOptions.Delete? options = null)
{
// Delete related deals first
var deals = await domain.Deal.GetMany(new RecordOptions.GetMany
{
Filters = [new() { FieldSchemaName = "Contact", Value = id }]
});
foreach (var deal in deals.Records)
await domain.Deal.Delete(deal.ID);
await base.Delete(id, options);
}
// Handle row-button actions from the view grid
public override async Task<EngineResponseBase> Run(string action, object[] args)
{
if (action == "MarkAnswered")
{
var id = (Guid)args[0];
var contact = await Get(id);
contact.CommunicationStatus = "Answered";
await Save(contact, new RecordOptions.Save { IsNew = false });
return new EngineResponseBase { Success = true };
}
return await base.Run(action, args);
}
}
Registering the form action handler on the engine
Override GetActionHandler in a hand-written partial class of the engine:
// Actions.cs (hand-written file in Engine project)
public partial class MyAppEngine
{
public override FormActionHandler? GetActionHandler(string entitySchemaName)
=> entitySchemaName switch
{
nameof(Property) => new FormActionHandler
{
EntitySchemaName = nameof(Property),
Action = PropertyAction.Handle
},
nameof(Contact) => new FormActionHandler
{
EntitySchemaName = nameof(Contact),
Action = ContactAction.Handle
},
_ => null
};
}
6.1 Overriding Collection Item Methods
A collection item has no storage of its own, so it gets no CRUD API. It does get an overridable methods class, wired into the domain next to the entities:
public class MyAppDomain(MyAppEngine engine) : DefaultMyAppDomain(engine)
{
public override IRecordMethods<Brand> Brand => new BrandMethods(this);
// Note the different interface: collection items are not records.
public override ICollectionItemMethods<GuidelineSection> GuidelineSection
=> new GuidelineSectionMethods(this);
}
Two hooks are available, both driven by the owning record's save:
| Member | When it runs |
|---|---|
Save(item, options) |
Before the owning record is persisted, once per item on the incoming record. Throwing aborts the whole save. |
Delete(item, options) |
Before the owning record is persisted, once per item that was on the stored record and is gone from the incoming one. |
ActionHandler |
On every change in the item's editor popup — the same handler shape entities use. |
CollectionItemOptions carries the context an item needs in place of a record id:
public class GuidelineSectionMethods(DefaultMyAppDomain domain)
: DefaultGuidelineSectionMethods(domain)
{
public override Task<GuidelineSection> Save(
GuidelineSection section,
CollectionItemOptions.Save options)
{
section.Title = Require(section.Title, "Section title");
// options.IsNew - the item is not on the stored record
// options.RootRecordId - id of the stored record it belongs to
// options.Root<Brand>() - the incoming root record, pending changes applied
// options.OwnerAs<T>() - the immediate owner (root record, or the
// containing item when the collection is nested)
// options.Path - collection items between root and this item
Validate(options.Root<Brand>()?.AssetGroups, section.HeaderTextColor?.Key);
return base.Save(section, options);
}
}
Reading the collection from options.Root<T>() rather than from storage matters:
sibling items and other fields on the record may be changing in the same save.
Delete is only invoked when a methods class actually overrides it — otherwise the
engine skips the read of the stored record entirely.
Item changes are audit-logged individually under the item's own schema name and id, with the owning record identified in the entry details. They are written only after the record itself has been persisted.
Action handlers in the item popup
The item editor popup dispatches through the same UpdateFormModel entry point as a
record form, so the whole of FormActionResult
applies inside the popup — showing and hiding fields and sections, replacing option
lists, changing labels and requirements.
The request differs in two ways from a record form's:
request.Outputholds the item's own values, and itsEntitySchemaNameis the collection-item schema name.request.ParentOutputholds the owning record form's values, so a handler can reach the record the item is being edited under — including its unsaved changes.
public override FormActionHandler? ActionHandler => new()
{
EntitySchemaName = nameof(GuidelineSection),
Action = request =>
{
FormActionResult result = new();
SectionKinds? kind = request.Output.GetValue<SectionKinds>(
nameof(GuidelineSection.SectionKind));
if (kind?.Key == SectionKinds.Header.Key)
result.ShowSection("Headersection");
else
result.HideSection("Headersection");
// Options sourced from a collection on the parent record.
IReadOnlyList<AssetsGroup> groups = request.ParentOutput
?.GetCollectionItems<AssetsGroup>(nameof(Brand.AssetGroups)) ?? [];
result.ChangeOptions(nameof(GuidelineSection.HeaderTextColor), ToOptions(groups));
return Task.FromResult(result);
}
};
request.CollectionItemContext identifies which item is being edited. On the popup's
OnInit its ChangeType is Added for a new item and FieldChanged for an existing
one, so a handler can seed defaults for new items with ChangeValue.
What the popup renders
The popup renders the collection item's configured form through the same component the record form uses, so the two look and behave alike: the form's sections in their configured order with their collapse and visibility defaults, the form's column count, the same field components, and the same validation presentation. Saving an item forces validation on every field and expands any collapsed section holding an error.
Field-level permissions apply inside the popup. A collection item with no
HasPermission rules inherits whatever the owning collection field granted; once any
rule is declared, a role without a matching rule gets no access. Field rules on the
item's own fields narrow that further. A field the user cannot read is not rendered;
one they can read but not edit renders read-only.
7. Form Action Handler
The form action handler is a server-side function the form calls on two events:
| Invoker | When called |
|---|---|
OnInit |
When the form first loads (new or existing record) |
OnInput |
When the user changes any field value |
The handler receives the current form state (FormOutput) and returns a list of FormChange instructions that the UI applies immediately - without a page reload.
Handler function
public static class PropertyAction
{
public static async Task<FormActionResult> Handle(FormActionRequest request)
{
FormActionResult result = new();
FormOutput form = request.Output;
// On Init
if (request.Invoker == FormActionInvokers.OnInit)
{
// Pre-populate fields for new records
if (form.IsNew)
result.ChangeValue(nameof(Property.PropertyStatus), "Incomplete");
// Lock the reference number once set
if (form.HasValue(nameof(Property.ReferenceNumber)))
result.DisableField(nameof(Property.ReferenceNumber));
}
// Read typed values
string? listingType = form.GetValue<string>(nameof(Property.Propertylistingtypes));
string? category = form.GetValue<string>(nameof(Property.PropertyCategory));
decimal? sellPrice = form.GetValue<decimal?>(nameof(Property.SellAskingPrice));
Guid? brokerId = form.GetValue<Guid?>(nameof(Property.Broker));
// Show/hide fields
if (listingType == "Sale")
{
result.ShowField(nameof(Property.SellAskingPrice));
result.HideField(nameof(Property.RentAskingPrice));
}
else if (listingType == "Rent")
{
result.HideField(nameof(Property.SellAskingPrice));
result.ShowField(nameof(Property.RentAskingPrice));
}
// Show/hide sections
if (category == "Land")
result.HideSection("Details");
else
result.ShowSection("Details");
// Collapse a section
result.CollapseSection("AdditionalDetails");
// Change a label dynamically
result.ChangeLabel(nameof(Property.AreaSize), "Land Area (m2)");
// Push options for an OnDemand field
var brokerOptions = await GetBrokerOptionsFromDb();
result.ChangeOptions(nameof(Property.Broker), brokerOptions);
// Change requirement at runtime
result.ChangeRequirement(nameof(Property.FloorLevel), RequiredField.Required);
// Push data to a custom component (Fields.Custom)
result.UpdateCustomData(nameof(Property.AreasMap),
new { Zoom = 14, Center = "33.888,35.495" });
// Trigger a redirect after save
result.WithAction(new FormActions.Redirect("/properties"));
// Open a confirmation dialog
result.WithAction(new FormActions.Dialog(
Title: "Confirm Delete",
Message: "Are you sure you want to delete this record?",
Buttons:
[
new FormDialogButton { Text = "Yes", Action = new FormActions.CloseDialog() },
new FormDialogButton { Text = "No", Action = null }
]
));
return result;
}
}
FormOutput - reading values
FormOutput form = request.Output;
bool isNew = form.IsNew;
Guid? id = form.RecordId;
Guid? userId = form.LoggedInUserId;
string entity = form.EntitySchemaName;
bool locked = form.IsLocked;
// Strongly-typed value retrieval
string? name = form.GetValue<string>(nameof(Contact.Name));
int? rooms = form.GetValue<int?>(nameof(Property.BedroomNumber));
decimal? price = form.GetValue<decimal?>(nameof(Property.SellAskingPrice));
bool? active = form.GetValue<bool?>(nameof(Contact.IsActive));
Guid? owner = form.GetValue<Guid?>(nameof(Property.Broker));
DateTime? closing = form.GetValue<DateTime?>(nameof(Property.ClosingDate));
// Check presence
bool hasBroker = form.HasValue(nameof(Property.Broker));
FormActionResult - all available changes
| Method | Target | Description |
|---|---|---|
ChangeValue(field, value) |
Field | Set a field's value |
ChangeAddress(field, address) |
Address field | Set an Address field |
ChangeOptions(field, options) |
OnDemand / Predefined | Replace the option list |
ChangeLabel(field, label) |
Field | Change the display label |
HideField(field) |
Field | Hide a field |
ShowField(field) |
Field | Show a hidden field |
DisableField(field) |
Field | Make read-only |
EnableField(field) |
Field | Re-enable a disabled field |
ChangeRequirement(field, req) |
Field | Change required state at runtime |
CollapseSection(section) |
Section | Collapse a form section |
ExpandSection(section) |
Section | Expand a collapsed section |
HideSection(section) |
Section | Hide a section |
ShowSection(section) |
Section | Show a hidden section |
UpdateCustomData(field, data) |
Custom field | Push JSON data to a Razor component |
WithAction(FormActions) |
Form | Navigate, show dialog, or close dialog |
FormActions types:
new FormActions.Redirect(url)- navigate to a URLnew FormActions.CloseDialog()- close a dialog opened by another actionnew FormActions.Dialog(title, buttons, message?)- open a modal dialog
8. Security & Permissions
Preconfigured roles
settings.Security.IsRoleBased = true;
settings.Security.PreconfiguredRoles.Add(new() { Code = "Broker", Name = "Broker" });
settings.Security.PreconfiguredRoles.Add(new() { Code = "Telemarketer", Name = "Telemarketer" });
settings.Security.PreconfiguredRoles.Add(new() { Code = "CallCenter", Name = "Call Center" });
settings.Security.PreconfiguredRoles.Add(new() { Code = "Media", Name = "Media" });
EntityPermissions flags
| Flag | Description |
|---|---|
Read |
View records |
Create |
Create new records |
Update |
Edit existing records |
Delete |
Delete records |
Append |
Associate other records to this entity (parent side) |
AppendTo |
Associate this record to other records (child side) |
Share |
Share records with other users |
Assign |
Assign records to another user/team |
All |
All of the above |
FieldPermissions flags
| Flag | Description |
|---|---|
Read |
View field value |
Edit |
Edit field value |
All |
Read + Edit |
AccessScope - data scoping
| Value | Description |
|---|---|
None |
No access |
User |
Only records owned by / created by the logged-in user |
Unit |
Records belonging to the user's business unit |
Parental |
User's unit + child units in the hierarchy |
Global |
All records (organisation-wide) |
Applying scopes
// Same scope for read and write
.HasPermission("Broker", EntityPermissions.All, AccessScope.User)
// Independent read/write scopes
.HasPermission("Broker",
EntityPermissions.Read | EntityPermissions.Update,
readScope: AccessScope.Global, // can see all records
writeScope: AccessScope.User) // can only edit their own
Vertical security
Set settings.Security.IsVertical = true to enable org-hierarchy-based access control (organization business group business unit).
9. Search
CDM supports three search providers, selectable at engine configuration time.
9.1 Cosmos search (default)
Enabled automatically when WithData(database, storage) runs. Performs CONTAINS-style keyword queries on all IsSearchable fields. No extra configuration required.
9.2 Azure AI Search
// Via connection strings
engine.WithAzureAISearch(
endpoint: "https://my-search.search.windows.net",
apiKey: "<admin-key>",
indexPrefix: "myapp-", // optional; prefixes each entity index name
globalIndexName: "myapp-global" // optional; name of the cross-entity index
);
// Or via settings object
engine.WithAzureAISearch(new AzureAISearchSettings
{
Endpoint = "https://my-search.search.windows.net",
ApiKey = "<admin-key>",
IndexPrefix = "myapp-"
});
Mark fields for indexing using attributes or fluent builder:
// Attributes on EntitySet properties
[Searchable(Boost = 2.0)]
[Sortable(IsDefault = true)]
[Filterable]
[Facetable]
[GlobalSearchField(IsTitle = true)]
public Fields.SingleLine Title { get; set; } = new() { IsPrimary = true };
[VectorField(1536)]
public Fields.SingleLine TitleVector { get; set; } = new() { IsVisible = false };
Or via fluent builder API:
builder.Entity<Property>()
.GetField(nameof(Property.Title))
.IsSearchable(boost: 2.0)
.IsSortable(isDefault: true)
.IsFilterable()
.IsFacetable()
.IsGlobalSearchTitle()
.Entity
.GetField(nameof(Property.TitleVector))
.AsVector(dimensions: 1536, profile: "default");
9.3 Custom search provider
public class MySearchProvider : ISearchProvider
{
public Task<SearchResult<T>> SearchAsync<T>(SearchRequest request, ...) { ... }
public Task<GlobalSearchResult> SearchAllAsync(SearchRequest request, ...) { ... }
}
// Register
engine.WithSearch(new MySearchProvider());
10. Settings Module
The Settings module adds a dedicated portal section (accessible at /settings) for managing system configuration data such as security roles, geographic data, and custom lookup tables.
builder.AddCDMServer<MyEngine, MyCosmos, MyDomain>(settings, config =>
{
config.Settings = s => s
// Built-in: role-based access control management
.HasRoleBasedAccessSettings(engine.Security.DataStorage)
// Built-in: country/subdivision/city address data management
.HasAddressesSettings(new MyAddressOperations())
// Custom settings area via the fluent builder
.HasArea("Currencies", "Currencies")
.HasEntity("Currency", "Currency", "Currencies")
.HasField("Code", new Fields.SingleLine { Required = RequiredField.Required, IsPrimary = true })
.HasField("Symbol", new Fields.SingleLine())
.HasForm()
.HasFormField("Code")
.HasFormField("Symbol")
.Entity
.HasView()
.HasViewField("Code", 100)
.HasViewField("Symbol", 80)
.Entity
.HasMethods(new CurrencyMethods())
.Area
.Settings;
});
Custom area definition (preferred pattern)
Encapsulate an area's structure in a CDMSettingsAreaDefinition:
public class CurrencySettingsDefinition : CDMSettingsAreaDefinition
{
public override void Define(CDMSettingsBuilder builder)
{
builder
.HasArea("Currencies", "Currencies")
.HasEntity("Currency", "Currency", "Currencies")
.HasField("Code", new Fields.SingleLine { IsPrimary = true })
.HasField("Name", new Fields.SingleLine())
.HasForm()
.HasFormField("Code")
.HasFormField("Name")
.Entity
.HasView()
.HasViewField("Code", 100)
.Entity
.HasMethods(new CurrencyMethods())
.Area;
}
}
// Register
config.Settings = s => s
.Define(new CurrencySettingsDefinition())
.Define(new AnotherDefinition());
11. Engine Advanced Features
Audit Log
// Settings
settings.AuditLog = new() { Enabled = true, TableName = "logs" };
When enabled, CDM automatically records every Save and Delete operation with user ID, timestamp, entity type, record ID, and changed values in Azure Table Storage.
Tracking (draft-change log for publishing)
Tracking is the Azure Table Storage-backed log of pending draft changes that
backs the [Publishable] workflow below — not page-view analytics. It is
enabled automatically whenever the module has at least one publishable entity;
WithTracking only needs to be called explicitly to point it at different
storage or to force it on/off independent of that default:
engine.WithTracking(storageConnectionString, tableName: "tracking");
// Equivalent via settings — CDMTrackingSettings
settings.Tracking = new()
{
Enabled = null, // null = follow Module.HasPublishing (default)
ConnectionString = null, // falls back to Storage.ConnectionString
TableName = null // falls back to "{BaseName}changes"
};
Publishing as drafts ([Publishable])
Mark an entity [Publishable] (or builder.Entity<T>().Publishable()) to make
every save on that entity a draft: the record is stored normally and
immediately readable, but the change is also appended to the Tracking log as
pending. The portal header shows a publish button with the pending-change
count; publishing raises the module's publish event and then clears the
tracked entries for the ones that were included.
[Entity("Article", "Articles")]
[Publishable]
public class Article : EntitySet { /* ... */ }
// Or via the fluent builder
builder.Entity<Article>().Publishable();
Because the build declares the publish event as a partial method, a project
with at least one [Publishable] entity fails to compile until a hand-written
partial class of the generated engine implements it:
// Publish.cs (hand-written file in the Engine project)
public partial class MyAppEngine
{
private partial async Task OnPublishAsync(PublishEventArgs args)
{
// args.Changes - pending TrackingRecord entries, oldest first
// args.PartitionKey - tenant partition being published, if tenant-scoped
foreach (TrackingRecord change in args.Changes)
await PublishToLive(change.EntitySchemaName, change.RecordId);
}
}
If OnPublishAsync throws, the tracked changes stay pending and the user can
retry — publishing is all-or-nothing per invocation.
This is a different mechanism from the dual-database WithPublishing below:
[Publishable] tracks which records have unpublished edits in a single
database, while WithPublishing copies finished records into a separate
publishing database. A project can use either independently, or both together
(e.g. draft-track changes, then have the publish event copy the record into the
publishing database).
Sequence - auto-increment fields
Use IsSequential = true on a Fields.WholeNumber to get a per-entity auto-incrementing integer backed by Azure Table Storage:
public Fields.WholeNumber ReferenceNumber { get; set; } = new()
{
Editable = false,
IsSequential = true
};
Sequences are stored in the table named by CDMStorageSettings.SequenceTableName.
Entity reference delete protection
Deleting a record is blocked while another record still references it through an
EntityReference field — on by default, no configuration needed. The check runs
once per delete request, in the engine itself (not in generated code), by scanning
every EntityReference field across the whole module for one that targets the
entity being deleted, and querying for a match the same way a picker's option set
already does.
DELETE Company/{id}
-> 400 {
"CDMError": "cdm-error",
"IsHandled": true,
"Message": "This record cannot be deleted: it is still referenced by
2 Brands through 'Parent'.",
"TotalRecordCount": 2,
"Records": [
{ "RecordId": "…", "EntitySchemaName": "Brand", "Name": "MelonCut",
"Reason": "Brand · Parent" },
{ "RecordId": "…", "EntitySchemaName": "Brand", "Name": "LazyBanana",
"Reason": "Brand · Parent" }
]
}
Every referring field is queried, not just the first one that matches, so the answer is complete: an admin does not unlink one field only to hit the same wall again. The first five referencing records travel back as records rather than as names inside a sentence, so the form dialog lists them as links straight to the records that have to be changed, and counts the rest ("and 7 more").
The response is a handled error: the dialog shows the message and the links, and offers no technical details — nothing failed.
The form asks before it asks the user. Clicking Delete first posts to
Methods/CanDeleteRecord, which runs the same permission gate and the same guard
without touching any data; only when it comes back clean does the "Are you sure?"
popup open. Being asked to confirm something that is then refused reads as a broken
promise. DeleteRecord still runs the guard itself — a reference added between the
check and the confirmation is caught, and API callers that skip the check are
guarded all the same.
Cascading deletes are unaffected: deleting a parent record still deletes its one-to-many/many-to-many children as usual (this check only guards the top-level delete request, not the cascade steps it triggers), so removing a whole subtree in one action still works.
Opt a specific field out where an orphaned reference is harmless — e.g. a soft link that's read defensively rather than counted on:
public Fields.EntityReference RelatedArticle { get; set; } = new("Article", config =>
config.AllowDeleteWhenReferenced());
There is no entity-wide switch — the check is declared per field, at the same place the reference itself is declared, so turning it off is always an explicit, visible decision next to the field it affects.
Handled and unhandled errors
A failed request says whether the server refused on purpose. The form error dialog reads that flag and shows technical details only when something actually broke — an anticipated rejection is already the whole story, and a stack trace next to it only suggests a bug where there is none.
// Anticipated: the message is written for the user, and may name records.
throw new CDMUserException("Pick a company before adding a brand.");
// Anticipated: field validation, raised anywhere in the engine.
throw new ValidationException("'Owner' must reference a Company.");
Endpoints classify what escapes them with CDMErrorPayload.FromException(exception):
a CDMUserException or ValidationException anywhere in the chain is handled;
anything else is a defect and travels with its exception chain (types and messages
— stack traces stay on the server).
catch (Exception e)
{
CDMErrorPayload payload = CDMErrorPayload.FromException(e);
if (!payload.IsHandled)
Console.WriteLine($"[MethodsController] DeleteRecord failed: {e}");
return BadRequest(payload);
}
On the client every failed call throws a CDMRequestException — an
HttpRequestException, so existing handlers are unaffected — carrying IsHandled,
Records, and ServerDetails. An endpoint that still returns a plain
BadRequest("…") keeps working: any 4xx with a message is read as handled, while a
5xx or an empty body is not.
| Failure | Dialog shows | Details button |
|---|---|---|
| Delete blocked by references | message + links to the first five referring records | no |
| Validation rejected a value | message | no |
BadRequest("Cannot delete a system role.") |
message | no |
| An exception escaped an endpoint | message | yes — server exception chain, then the client one |
Publishing (dual-database)
engine.WithPublishing(publishingDatabase, publishingStorage);
Useful for staging-to-production publish workflows. Normal reads come from Database; published records are written to PublishingDatabase.
Azure Maps integration
settings.Maps = new()
{
AzureSubscriptionKey = "<key>",
ValidateAddresses = true // geocode and validate addresses on save
};
When ValidateAddresses = true, saving a record with an Address field triggers server-side geocoding via Azure Maps to validate and enrich the address with coordinates.
12. Complete Sample - BluSky Portal
The following is the complete, real usage from the BluSky real-estate CRM built on CDM.
Program.cs
using CDM;
using AngryMonkey.Cloud.CDM;
using BluSky.Portal;
using BluSky.Portal.DataContract;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient();
CDMSettings settings = new()
{
Title = "BluSky Portal",
Theme = new() { AccentColor = "#051c51" },
AuditLog = new() { Enabled = true }
};
settings.Security.IsRoleBased = true;
settings.Security.PreconfiguredRoles.Add(new() { Code = "Broker", Name = "Broker" });
settings.Security.PreconfiguredRoles.Add(new() { Code = "Telemarketer", Name = "Telemarketer" });
settings.Security.PreconfiguredRoles.Add(new() { Code = "CallCenter", Name = "Call Center" });
settings.Security.PreconfiguredRoles.Add(new() { Code = "Media", Name = "Media" });
builder.Configuration.GetSection("CDM").Bind(settings);
builder.Services.AddCors(options =>
options.AddPolicy("PortalCors", policy =>
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
builder.AddCDMServer<BluSkyPortalEngine, BluSkyPortalCosmos, BluSkyPortalDomain>(settings);
var app = builder.Build();
app.UseCors("PortalCors");
app.UseCDM<BluSkyPortalDomain>();
await app.RunAsync();
Builder Program.cs
using AngryMonkey.Cloud.CDM;
using BluSky.Portal.Builder.Entities;
var builder = new CDMBuilder("BluSky.Portal");
builder.Module.DefaultCountryCode = "LB";
builder.Entity<Contact>()
.GetView()
.HasPermission("CallCenter")
.HasPermission("Telemarketer")
.HasPermission("Broker")
.Entity
.GetForm()
.HasPermission("CallCenter")
.HasPermission("Telemarketer")
.HasPermission("Broker");
builder.Entity<Property>()
.GetForm("BrokerForm")
.HasPermission("Broker")
.Entity
.GetForm("MediaForm")
.HasPermission("Media")
.Entity
.GetView()
.HasPermission("Broker")
.HasPermission("Media");
builder.Entity<Media>()
.GetForm()
.HasPermission("Media")
.HasPermission("Broker")
.Entity
.GetView()
.HasPermission("Media")
.HasPermission("Broker");
builder.Entity<Deal>();
builder.Entity<Document>();
builder.Entity<Region>();
builder.HasDashboard()
.HasDashboardView<Contact>()
.HasDashboardView<Property>()
.HasDashboardView<Deal>()
.HasDashboardView<Region>();
await builder.GenerateAndReplaceFiles(new CDMBuilderOptions
{
CreateMainProjects = false
});
Entity example - Contact (from BluSky)
[Entity("Contact", "Contacts")]
public class Contact : EntitySet
{
public Fields.SingleLine FirstName { get; set; } = new() { Required = RequiredField.Required, IsVisible = false };
public Fields.SingleLine LastName { get; set; } = new() { Required = RequiredField.Required, IsVisible = false };
public Fields.SingleLine Name { get; set; } = new() { IsVisible = false, IsPrimary = true };
public Fields.SingleLine PhoneNumber { get; set; } = new(Fields.SingleLineValidation.PhoneNumber)
{
Required = RequiredField.Required
};
public Fields.Predefined CommunicationStatus { get; set; } = new("CommunicationStatus")
{
Required = RequiredField.Required,
IsVisible = false,
DefaultValue = "ToBeContacted"
};
public Fields.Predefined ClientRole { get; set; } = new("ClientRoles")
{
Required = RequiredField.Required,
DefaultValue = "None",
IsVisible = false
};
public Fields.OnDemand Broker { get; set; } = new()
{
Behavior = FieldBehavior.DropDown,
MultipleValue = true,
IsVisible = false
};
public Fields.Money BuyerBudgetRent { get; set; } = new() { Label = "Budget (Rent)" };
public Fields.Money BuyerBudgetBuy { get; set; } = new() { Label = "Budget (Buy)" };
public Fields.Address BuyerCities { get; set; } = new()
{
Label = "Cities",
MultipleValue = true,
Country = new() { IsRequired = true, IsEnabled = false, IsVisible = false, DefaultValue = "LB" },
Subdivision = new() { IsRequired = true },
SubdivisionChild = new() { IsRequired = true },
City = new() { IsRequired = true }
};
public Fields.Predefined BuyerPropertyTypes { get; set; } = new("PreferredPropertyTypes")
{
Label = "Property Types",
MultipleValue = true
};
protected override FormConfig[] Forms =>
[
new FormConfig
{
Sections =
[
new FormSectionConfig("Personal Info")
{
Fields =
[
nameof(PhoneNumber),
nameof(FirstName),
nameof(LastName),
nameof(Channel),
nameof(CommunicationStatus),
nameof(LeadStatus),
nameof(ClientRole),
nameof(Broker),
],
},
new FormSectionConfig("Seller Basic Info")
{
IsVisibleByDefault = false,
Fields =
[
nameof(ListingType),
nameof(SellerAddress),
nameof(SellerAskingPriceSale),
nameof(SellerAskingPriceRent),
],
},
new FormSectionConfig("Buyer Basic Info")
{
IsVisibleByDefault = false,
Fields =
[
nameof(RequirementType),
nameof(BuyerCities),
nameof(BuyerPropertyTypes),
nameof(BuyerBudgetBuy),
nameof(BuyerBudgetRent),
],
},
],
},
];
protected override ViewConfig[] Views =>
[
new ViewConfig
{
Fields =
[
nameof(Name),
nameof(PhoneNumber),
nameof(ClientRole),
nameof(CommunicationStatus),
],
RowButtons =
[
new RowButtonModel
{
SchemaName = "MarkAnswer",
Text = "Answer",
Tooltip = "Mark as Answered",
VisibleFieldSchemaName = nameof(CommunicationStatus),
VisibleWhenFieldValueIn = ["ToBeContacted", "NotAnswered"]
},
new RowButtonModel
{
SchemaName = "MarkNoAnswer",
Text = "No Answer",
VisibleFieldSchemaName = nameof(CommunicationStatus),
VisibleWhenFieldValueIn = ["ToBeContacted", "Answered"]
},
],
},
];
}
Entity example - Region (with Fields.Custom)
[Entity("Region", "Regions")]
public class Region : EntitySet
{
public Fields.SingleLine Name { get; set; } = new() { Required = RequiredField.Required, IsPrimary = true };
public Fields.OnDemand Broker { get; set; } = new() { Required = RequiredField.Required, MultipleValue = false };
public Fields.Address Areas { get; set; } = new()
{
MultipleValue = true,
Country = new() { IsRequired = true, IsEnabled = false, IsVisible = false, DefaultValue = "LB" },
Subdivision = new() { IsRequired = true },
SubdivisionChild = new() { IsRequired = true },
City = new() { IsRequired = false }
};
// Custom Razor component embedded in the form
public Fields.Custom AreasMap { get; set; } = new() { ComponentKey = "RegionMap" };
protected override FormConfig[] Forms =>
[
new FormConfig
{
Sections =
[
new FormSectionConfig("General")
{
Fields = [ nameof(Name), nameof(Broker), nameof(Areas) ],
},
new FormSectionConfig("Map")
{
Fields = [ nameof(AreasMap) ],
},
],
},
];
protected override ViewConfig[] Views =>
[
new ViewConfig { Fields = [ nameof(Name), nameof(Broker) ] },
];
}
Entity example - Property (multiple forms + [OneToMany])
[Entity("Property", "Properties")]
public class Property : EntitySet
{
public Fields.SingleLine Title { get; set; } = new() { Editable = false, IsPrimary = true };
public Fields.SingleLine ReferenceNumber { get; set; } = new() { Editable = false };
public Fields.EntityReference CoverImage { get; set; } = new(nameof(Media));
public Fields.EntityReference CoverVideo { get; set; } = new(nameof(Media));
public Fields.Address Address { get; set; } = new()
{
Required = RequiredField.Required,
Country = new() { IsRequired = true, IsEnabled = false, IsVisible = false, DefaultValue = "LB" },
Subdivision = new() { Codes = ["BA", "JL", "AS"], IsRequired = true },
SubdivisionChild = new() { IsRequired = true },
City = new() { IsRequired = true },
Map = new() { IsRequired = false }
};
public Fields.SingleLine OwnerPhoneNumber { get; set; } = new(Fields.SingleLineValidation.PhoneNumber)
{
Label = "Phone Number",
Required = RequiredField.Required,
Behavior = FieldBehavior.Autocomplete
};
public Fields.Predefined Propertylistingtypes { get; set; } = new("PropertyListingType")
{
Required = RequiredField.Required,
Label = "Property Listing Type",
DefaultValue = "Sale"
};
public Fields.Predefined PropertyStatus { get; set; } = new("PropertyStatuses")
{
DefaultValue = "Incomplete",
Behavior = FieldBehavior.Buttons
};
public Fields.Money SellAskingPrice { get; set; } = new() { Description = "Public Price" };
public Fields.Money RentAskingPrice { get; set; } = new() { Description = "Public Price" };
public Fields.OnDemand Broker { get; set; } = new()
{
Required = RequiredField.Required,
MultipleValue = false
};
public Fields.DecimalNumber AreaSize { get; set; } = new()
{
Label = "Area Size (m2)",
Required = RequiredField.Required,
Description = "Enter the Area Size m2"
};
public Fields.WholeNumber BedroomNumber { get; set; } = new();
public Fields.WholeNumber BathroomsNumber { get; set; } = new();
public Fields.WholeNumber ParkingSpotsNumber { get; set; } = new();
public Fields.MultipleLines Notes { get; set; } = new() { FullWidth = true };
// One-to-many relationship - Property has many Media items
[OneToMany("Property")]
public Media[] Media { get; set; } = [];
protected override FormConfig[] Forms =>
[
// Form for Broker role
new FormConfig
{
SchemaName = "BrokerForm",
Sections =
[
new FormSectionConfig("Owner")
{
Fields = [ nameof(OwnerPhoneNumber), nameof(OwnerFirstName), nameof(OwnerLastName) ],
},
new FormSectionConfig("Basic")
{
Fields =
[
nameof(Title), nameof(ReferenceNumber),
nameof(PropertyStatus), nameof(Address),
nameof(Broker)
],
},
new FormSectionConfig("Classification")
{
Fields =
[
nameof(Propertylistingtypes), nameof(PropertyType),
nameof(SellAskingPrice), nameof(RentAskingPrice),
],
},
new FormSectionConfig("Areas")
{
Fields = [ nameof(AreaSize), nameof(TerraceAreaSize), nameof(Notes) ],
},
new FormSectionConfig("Details")
{
Fields =
[
nameof(FloorLevel), nameof(FurnishingStatus),
nameof(BedroomNumber), nameof(BathroomsNumber),
nameof(ParkingSpotsNumber), nameof(Amenities),
],
},
new FormSectionConfig("Media And Marketing")
{
Fields = [ nameof(CoverImage), nameof(CoverVideo) ],
Relationships = [ $"Property{nameof(Media)}" ],
},
],
},
// Simplified form for Media role
new FormConfig
{
SchemaName = "MediaForm",
Sections =
[
new FormSectionConfig("Basic")
{
Fields = [ nameof(Title), nameof(ReferenceNumber), nameof(PropertyStatus), nameof(Address) ],
},
new FormSectionConfig("Media And Marketing")
{
Fields = [ nameof(CoverImage), nameof(CoverVideo) ],
Relationships = [ $"Property{nameof(Media)}" ],
},
],
},
];
protected override ViewConfig[] Views =>
[
new ViewConfig
{
Fields =
[
nameof(Title),
nameof(OwnerFirstName),
nameof(OwnerPhoneNumber),
nameof(PropertyCategory),
nameof(Propertylistingtypes),
],
},
];
}
Custom Domain - Domain.cs (BluSky)
public class BluSkyPortalDomain(BluSkyPortalEngine engine, CloudGeographyClient cloudGeography)
: DefaultBluSkyPortalDomain(engine)
{
public override IRecordMethods<Contact> Contact => new ContactMethods(this, cloudGeography);
public override IRecordMethods<Property> Property => new PropertyMethods(this);
public override IRecordMethods<Deal> Deal => new DealMethods(this);
public override IRecordMethods<Media> Media => new MediaMethods(this);
public override IRecordMethods<Document> Document => new DocumentMethods(this);
public override IRecordMethods<Region> Region => new RegionMethods(this);
}
13. Common Pitfalls
Editing files inside auto-generated/
All files carrying the // Auto-Generated header are overwritten on every builder run. Place custom logic in hand-written files that inherit from the generated base classes.
Missing UseCDM<TDomain>()
AddCDMServer registers services; UseCDM<TDomain>() mounts the middleware pipeline (routing, SignalR hub, static assets). Both are required.
Roles defined after AddCDMServer
Roles must be added to settings.Security.PreconfiguredRoles before AddCDMServer is called.
DefaultValue key mismatch
DefaultValue on a Fields.Predefined must match the Key of an OptionSetOption, not its display Value:
// OptionSetOption: new() { Key = "ToBeContacted", Value = "To Be Contacted" }
// WRONG: DefaultValue = "To Be Contacted"
// CORRECT:
DefaultValue = "ToBeContacted"
Missing IsPrimary field
Every entity needs exactly one field with IsPrimary = true. This drives the record title in pickers and the default search column.
Relationship schema name mismatch
The relationship schema name in Relationships = [...] is auto-generated as "{ToSchemaName}{FromSchemaName}":
// [OneToMany("Property")] public Media[] Media { get; set; } = [];
// Relationship schema name = "Property" + "Media" = "PropertyMedia"
Relationships = [ "PropertyMedia" ] // correct
Relationships = [ "Media" ] // wrong - will silently render nothing
Stale form or view field names after a refactor
Fields = [...] entries that match no field on the entity are skipped silently. This
bites hardest when a relationship is converted to a collection: nameof(Brand) still
compiles once the Brand property is gone, because it resolves to the type name.
The builder reports each one in its preflight summary:
Warnings:
! GuidelineSection form 'General' section 'Section' references 'Brand', which is not a field on this entity. It was skipped.
Converting a relationship to a collection item
An entity that becomes a CollectionItemSet loses its storage, controllers, and record
CRUD. Expect to change, in order:
builder.Entity<T>()registration — remove it; collection items are discovered from the parent'sFields.Collection<T>field.[ManyToOne]/[OneToMany]properties on both sides — replaced by the collection field.Relationships = [...]in the parent form — becomesFields = [nameof(TheCollection)].- Stale
nameof(Parent)entries in the item's own form and view configs (see above). - The domain override —
IRecordMethods<T>becomesICollectionItemMethods<T>. - The methods class —
RecordOptions.SavebecomesCollectionItemOptions.Save, and parent lookups that went through storage readoptions.Root<TParent>()instead.
Running the builder while the portal is running
The builder deletes and recreates files inside auto-generated/ folders. Stop the portal process first to avoid file-lock errors.
14. External Client - CDM.ExternalClient
AngryMonkey.CDM.ExternalClient is a small, dependency-free HTTP client for
calling a deployed CDM portal's API from outside the portal itself — a
backend service, a script, a mobile app, or any process that is not a CDM
Blazor host. Unlike the auto-generated MyApp.Portal.Client (used inside the
portal's own Blazor components), it needs no generated per-entity code: one
generic client works against any entity by schema name.
Configuring and creating a client
using CDM;
ExternalClientConfiguration configuration = new()
{
BaseUrl = "https://myapp-portal.example.com",
ApiKey = "<api-key>", // sent as X-API-Key, if your host validates one
BearerToken = null, // or sent as Authorization: Bearer <token>
Timeout = TimeSpan.FromSeconds(30),
ThrowOnError = true // false = failed calls return null instead of throwing
};
using ExternalClient client = new(configuration);
// Or supply your own HttpClient (e.g. from IHttpClientFactory):
// using ExternalClient client = new(httpClient, configuration);
BaseExternalClient<Contact> contacts = client.For<Contact>();
// Or override the entity's URL path segment explicitly:
// BaseExternalClient<Contact> contacts = client.For<Contact>("contacts");
client.For<T>() resolves the entity's URL path segment from T's Type
property (falling back to the type name), lower-cased, and hits
api/external/{path}/... endpoints on the host.
BaseExternalClient<T> operations
| Method | Description |
|---|---|
SaveAsync(entity, isNew, fields?, userId?, ct) |
Create or update a record. fields limits which properties are written |
GetAsync(id, fields?, userId?, ct) |
Fetch a single record by id |
GetNewAsync(parentSchemaName?, parentId?, userId?, ct) |
Get a blank record pre-populated with defaults, optionally scoped to a one-to-many parent |
GetManyAsync(parentSchemaName?, parentId?, page, count, fields?, userIds?, businessUnitIds?, recordsIds?, userId?, ct) |
Paged query, with optional owner/business-unit/id filtering |
GetManyAsOptionSetAsync(parentSchemaName?, parentId?, userId?, ct) |
Records as id → display value pairs, for building a picker without loading full records |
DeleteAsync(id, userId?, ct) |
Delete a record; returns whether it succeeded |
FormModelAsync(formModel, userId?, ct) |
Runs the same form-model resolution the portal UI uses (metadata + current field state) |
RunActionAsync(action, args, userId?, ct) |
Invokes a domain method's Run(action, args) override — the same entry point row-button actions use |
Every call is async and accepts a CancellationToken. userId impersonates a
user for permission evaluation when the host allows external callers to specify
one; omit it to run as whatever identity the API key/bearer token maps to.
Other external clients
| Client | Purpose |
|---|---|
GeographyExternalClient |
AddCityAsync(...) — add a city to the module's geography lookup table from outside the portal |
LookupExternalClient |
Read/write named lookup categories: GetSuggestionsAsync, GetEntriesAsync, GetAllAsync, GetAllEntriesAsync, AddAsync |
GlobalSearchExternalClient |
SearchAsync(SearchRequest, userId?, ct) — run the same cross-entity global search the portal UI uses |
SecurityClient |
Read-only, periodically-refreshed cache of the security model: roles, role membership, and effective entity/field/form/view permissions, for a service that needs to make its own authorization decisions without round-tripping to the portal on every check |
SecurityClient polls the portal on RefreshInterval (constructor parameter)
and exposes synchronous lookups once InitializeAsync() has completed —
GetEffectiveEntityPermissions(userId, entitySchemaName),
GetEffectiveFieldPermissions(userId, entitySchemaName, fieldSchemaName),
CanReadForm / CanEditForm / CanReadView, IsUserInRole, IsGlobalAdmin,
and the full GetAccessMatrix(). Each of these external clients takes the same
HttpClient + ExternalClientConfiguration constructor pair as ExternalClient.
Additional Resources
- Serialization pipeline — how a record value travels from an editor to the database and back
- Field value pipeline — a field value's lifecycle inside a form
- Testing strategy
- CDM Repository: https://dev.azure.com/AngryMonkeyCloud/CDM
- NuGet: AngryMonkey.CDM
Last Updated: 2025 - Version 7.x
Native external data providers and CloudLogin
CDM entities can be backed by an external provider without treating the provider as a second CDM database. The generic runtime contract is IExternalDataProvider; CloudLogin is the first implementation. An integrated entity stores only a stable reference (ExternalProvider, ExternalEntityType, and ExternalEntityId) plus explicitly configured synchronized fields and CDM-owned metadata.
Data-source modes
ExternalDataSourceMode.External (“CloudLogin”) is a read-only view over the provider. Independent creation and deletion are rejected, the generated provider picker is required and read-only, and provider-owned fields are read-only.
ExternalDataSourceMode.ExternalAndCdm (“CloudLogin + CDM”) permits CDM-only records and optionally linked records. Lists combine persisted CDM records with currently available provider records. A linked provider record is de-duplicated; an unlinked provider record is synthesized as an unmanaged, read-only row and can be explicitly converted with CreateInCdm. Conversion never creates a CloudLogin entity and is idempotent for the external identifier.
Builder configuration
ExternalDataSources is the entry point, in the same shape as Fields: one branch per provider, one factory per entity type, and a typed builder for anything beyond the defaults.
Defaults — the entity declares nothing at all. Fields, the primary field, the form, and the view all come from the provider:
[Entity("Contact", "Contacts")]
public sealed class Contact : EntitySet
{
protected override ExternalEntityConfiguration? ExternalDataSource =>
ExternalDataSources.CloudLogin.User.Defaults(ExternalDataSourceMode.External);
}
Custom — the entity owns its fields and says which provider field each one follows:
protected override ExternalEntityConfiguration? ExternalDataSource =>
ExternalDataSources.CloudLogin.Workspace.Custom()
.Map(nameof(Name), CloudLoginWorkspaceField.Name)
.Map(nameof(BillingEmail), CloudLoginWorkspaceField.BillingEmail)
.Relate(nameof(Contacts), CloudLoginRelationship.Members, nameof(Contact));
DefaultsWith — the defaults, plus overrides for the ones to relabel or hide:
protected override ExternalEntityConfiguration? ExternalDataSource =>
ExternalDataSources.CloudLogin.Workspace.DefaultsWith()
.Map(CloudLoginDefaultFieldNames.Workspace.Name,
CloudLoginWorkspaceField.Name,
label: "Business Name");
A provider CDM does not know — field names are strings here, because only that provider knows them:
protected override ExternalEntityConfiguration? ExternalDataSource =>
ExternalDataSources.Custom("Stripe", "Customer")
.Map(nameof(Email), "email");
Provider field names are enums, one per entity type (CloudLoginWorkspaceField, CloudLoginUserField, CloudLoginSubscriptionField), so mapping a User field onto a Workspace does not compile. CDM field names are passed as nameof(...), and generation fails if one matches no declared field rather than leaving a mapping that silently never syncs. CloudLoginSchema is the single source of truth for the whole vocabulary, and CloudLoginSchemaDriftTests pins it against what CloudLoginExternalDataProvider actually emits.
Values are translated with a typed overload rather than a raw dictionary:
.Map(nameof(Status), CloudLoginSubscriptionField.Status, values => values
.Translate(CloudLoginSubscriptionStatus.Suspended, SubscriptionStatuses.Paused)
.Translate(CloudLoginSubscriptionStatus.Expired, SubscriptionStatuses.Cancelled))
Created/modified timestamps follow the provider wherever it reports one — including in Custom mode, since that is a property of the entity type rather than of how its fields were configured. Opt out with .WithoutExternalTimestamps().
The builder adds the generic reference picker, LinkedOn, LinkedBy, LastSyncedOn, error metadata, an External Data form section, and SyncExternal / OpenExternal row actions. The runtime domain wraps normal generated methods with ExternalRecordMethods<TRecord> and a registered provider.
Ownership and synchronization
Each ExternalFieldMapping declares External, Cdm, or Synchronized ownership. External fields are pulled and rendered read-only. CDM fields remain local. Synchronized fields declare Pull, Push, or Bidirectional behavior; provider updates only occur during an explicit manual Sync and only when that provider supports updates. CloudLogin's current service contract is read-only, so unsupported writes fail explicitly rather than being silently attempted.
When configured, CreatedOnExternalFieldName and ModifiedOnExternalFieldName make record lifecycle timestamps follow the external entity. Linking never substitutes its own time for these values. LinkedOn and LinkedBy independently record when and by whom the relationship was created and remain stable across repeated synchronization.
Lists, forms, navigation, and on-demand data
The generated picker calls the provider for current entities and stores the stable external ID. Combined views contain CDM-only, linked, and unmanaged external rows. CreateInCdm turns an unmanaged row into a linked CDM record; OpenExternal returns a record-specific provider deep link; and SyncExternal refreshes only configured fields and updates synchronization metadata.
Provider-backed relationships and permissions are retrieved on demand with GetRelationshipsAsync and GetPermissionsAsync. CloudLogin Workspace membership is exposed as a generic relationship to User IDs, and permission identifiers remain strings owned by CloudLogin. Data not configured as a CDM field can be requested directly through IExternalDataProvider without persisting it.
Webhooks
Applications expose their own authenticated webhook endpoint and translate the versioned CloudLogin event into ExternalWebhookEvent. Pass it to ExternalRecordMethods<TRecord>.ProcessWebhookAsync with a durable IExternalEventReceiptStore implementation. Processing finds linked records only, fetches the authoritative provider entity, applies configured pull mappings, records errors, and never creates CDM records. Successful event IDs are duplicate-protected; failed receipts are retryable. Production hosts should persist receipts and validate the CloudLogin HMAC signature before calling the processor.
Manual Sync remains available for recovery and deterministic refresh even when webhooks are enabled.
Extending the integration
To add a CloudLogin entity type: add its contract/service operation to CloudLoginExternalDataProvider, add its field enum and default templates to CloudLoginSchema, and expose it on ExternalDataSources.CloudLogin. The drift tests will tell you immediately if the enum and the provider disagree. A new external system implements IExternalDataProvider; no Business, Contact, or Subscription-specific runtime is required.
15. Aspire Integration
AngryMonkey.CDM.Aspire.Hosting adds a CDM server to a .NET Aspire AppHost as an ordinary project
resource, wired to whatever Cosmos, Storage, and CloudLogin resources the model already declares:
using AngryMonkey.Cloud.CDM.Aspire.Hosting;
var builder = DistributedApplication.CreateBuilder(args);
var cosmos = builder.AddAzureCosmosDB("cosmos");
var storage = builder.AddAzureStorage("storage");
var login = builder.AddCloudLogin<Projects.My_Login>("login");
var portal = builder.AddCDM<Projects.My_Portal>("portal", settings => settings.BaseName = "MyApp");
// The backend channel CDM's external data provider reads CloudLogin-owned records over. Its own
// call rather than a flag on WithReference: that key bypasses user identity, so it is granted only
// to servers that need it.
portal.WithReference(login).WithServiceAccess(login).WaitFor(login);
portal.WithReference(cosmos).WithReference(storage).WaitFor(cosmos).WaitFor(storage);
BaseName defaults to the resource name ("portal" above) and is not a label - it is what
Cloud.CDM.Config's naming conventions (§3) derive every Cosmos database, container, and table
name from, exactly as it does with no Aspire in the picture.
On the server side, AngryMonkey.CDM.Aspire is what a CDM server hosted under Aspire uses to bind
CDMSettings from what the AppHost wired, instead of repeating Cosmos/Storage connection details
in its own appsettings.json. The same configuration keys work with no AppHost anywhere in the
picture - an appsettings.json, a user secret, an environment variable set by hand - so a CDM
server stays runnable outside Aspire too.
CoconutSharp builds on both packages for environment-aware publishing (naming, identity, App Service targets per Dev/Staging/Production) without changing how CDM itself is configured - see its own README for the composed example (CloudLogin + CDM + a web frontend, deployed to more than one environment from one AppHost).
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- AngryMonkey.CDM.Models (>= 8.0.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 8.0.1 | 63 | 8/27/2026 |
| 8.0.0 | 67 | 8/26/2026 |
| 7.3.5 | 93 | 8/15/2026 |
| 7.3.4 | 98 | 8/3/2026 |
| 7.3.3 | 102 | 8/2/2026 |
| 7.3.2 | 100 | 8/2/2026 |
| 7.3.1 | 105 | 8/2/2026 |
| 7.3.0 | 95 | 8/1/2026 |
| 7.2.1 | 106 | 7/25/2026 |
| 7.2.0 | 102 | 7/25/2026 |
| 7.1.8 | 100 | 7/22/2026 |
| 7.1.7 | 101 | 7/21/2026 |
| 7.1.6 | 102 | 7/21/2026 |
| 7.1.5 | 97 | 7/21/2026 |
| 7.1.4 | 98 | 7/20/2026 |
| 7.1.3 | 106 | 7/13/2026 |
| 7.1.2 | 96 | 7/13/2026 |
| 7.1.1 | 111 | 7/9/2026 |
| 7.1.0 | 108 | 7/9/2026 |
| 7.0.9 | 102 | 7/8/2026 |