Skip to content

Define Entities

Daniel Dieckmann edited this page May 27, 2026 · 2 revisions

Define Entities

Every D365 F&O data entity that the consumer reads or writes needs a corresponding C# class. The class is mapped to the OData entity set via [Table], the composite key is declared via [Key] attributes and GetCompositeKey(), and per-property serialisation behaviour is controlled via [ODataField] and [JsonPropertyName].

A Minimal Entity

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using IntegratoR.Abstractions.Domain.Entities;
using IntegratoR.OData.Common.Annotations;

namespace MyProject.Domain.Entities;

[Table("LedgerJournalHeaders")]
public class LedgerJournalHeader : BaseEntity<string>
{
    [Key]
    [JsonPropertyName("dataAreaId")]
    public required string DataAreaId { get; set; }

    [Key]
    [JsonPropertyName("JournalBatchNumber")]
    [ODataField(IgnoreOnCreate = true)]
    public string? JournalBatchNumber { get; set; }

    [JsonPropertyName("JournalName")]
    public required string JournalName { get; set; }

    [JsonPropertyName("Description")]
    public required string Description { get; set; }

    public override object[] GetCompositeKey()
    {
        return [DataAreaId, JournalBatchNumber ?? "null"];
    }
}

Three contracts make this entity work end-to-end:

  1. [Table("LedgerJournalHeaders")] maps the class to the D365 OData entity set. The name is case-sensitive and almost always pluralLedgerJournalHeader is the entity, LedgerJournalHeaders is the entity set.
  2. BaseEntity<TKey> declares the abstract GetCompositeKey() that handlers use for composite-key URL construction and reflection-based structured logging via GetLoggingContext().
  3. [Key] plus GetCompositeKey() define which properties form the primary key and the order in which the framework passes them to OData reads.

BaseEntity<TKey>

BaseEntity<TKey> is an abstract base class in IntegratoR.Abstractions.Domain.Entities. The single type parameter is the primary-key data type — most D365 entities use string (the wire type for DataAreaId), but int, long, and Guid are equally valid:

public class DimensionParameters : BaseEntity<int>
{
    [Key]
    [JsonPropertyName("Key")]
    public required int Key { get; set; }

    public override object[] GetCompositeKey() => [Key];
}

BaseEntity<TKey> implements IEntity and IContext. Custom entities almost never need to extend either interface directly — inheriting from BaseEntity is sufficient.

Composite Keys

D365 F&O entities are almost always identified by a composite key. A journal header is keyed by DataAreaId + JournalBatchNumber. A journal line adds LineNumber as a third component. The pattern: mark every key property with [Key] and return the same fields from GetCompositeKey() in the same order:

public override object[] GetCompositeKey()
{
    return [DataAreaId, JournalBatchNumber ?? "null", LineNumber];
}

The order is significant — the OData URL the framework builds for GetByKeyQuery<T> reads the array positionally. A mismatch between [Key] order and GetCompositeKey() order produces silent lookup failures.

The ?? "null" fallback in the example above handles entity instances built before a server-generated key is assigned. For CreateCommand<T> this never matters (the entity has no key yet); for GetByKeyQuery<T> and UpdateCommand<T> the caller must populate every key component before sending the request.

The [ODataField] Attribute

[ODataField] controls per-property serialisation for create (POST) and update (PATCH) payloads. The two most-used flags:

[ODataField(IgnoreOnCreate = true)]
public string? JournalBatchNumber { get; set; }   // server-generated by D365 number sequence

[ODataField(IgnoreOnUpdate = true)]
public string CustomerAccount { get; set; } = "";   // immutable business key after creation

[ODataField(IgnoreOnCreate = true, IgnoreOnUpdate = true)]
public decimal JournalTotalDebit { get; set; }      // fully read-only, calculated by D365
Flag Effect Typical use
IgnoreOnCreate = true Property is excluded from the POST payload Number-sequence fields, computed totals, server defaults
IgnoreOnUpdate = true Property is excluded from the PATCH payload Primary-key parts, immutable business keys

The framework reads the attribute via reflection inside ODataService<T>.AddAsync / UpdateAsync and strips the matching properties from the JSON body. The consumer code can populate the full entity (including fields the server will overwrite); the framework filters at serialisation time.

Common Pitfall

Setting a field marked IgnoreOnCreate = true and then calling CreateCommand<T> silently drops the value. The record creates successfully but with the server's default instead of the supplied value. When in doubt, read the entity source for the [ODataField] annotations before populating the entity.

CSDL-Driven Annotations

The [ODataField] attribute also carries CSDL-derived annotations populated by the code generator: AllowEdit, AllowEditOnCreate, IsRequired, EdmType, Label. These come from the D365 $metadata document and are the source of truth. The effective exclusion rule is:

Excluded from create = IgnoreOnCreate OR AllowEditOnCreate == false
Excluded from update = IgnoreOnUpdate OR AllowEdit          == false

Hand-written entities can ignore the CSDL-derived properties and use the simple IgnoreOn* flags. Generated entities carry both — the framework respects either.

Property Names — [JsonPropertyName]

D365 F&O entity sets contain roughly 19,600 PascalCase fields and 479 camelCase legacy X++ system fields (dataAreaId, recId, validFrom, validTo, inventDimId, itemId, custAccount, transDate, …). The CLR property name should be PascalCase by C# convention and the wire name pinned with [JsonPropertyName]:

[JsonPropertyName("dataAreaId")]   // camelCase wire name, PascalCase CLR property
public required string DataAreaId { get; set; }

[JsonPropertyName("JournalName")]  // PascalCase both sides — common case
public required string JournalName { get; set; }

The IntegratoR LINQ-to-OData translator honours [JsonPropertyName] in filter, select, and expand expressions (since v1.3.3). A predicate like h => h.DataAreaId == "USMF" correctly emits $filter=dataAreaId eq 'USMF' against D365. Without the attribute, the translator would emit DataAreaId eq 'USMF' and D365 would respond with "Could not find a property named 'DataAreaId'".

This applies to nested navigation paths as well:

[JsonPropertyName("nestedNav")]
public Nested? NavigationProperty { get; set; }

// usage:
h => h.NavigationProperty!.Name == "X"
// emits: $filter=nestedNav/Name eq 'X'

Enum Properties

D365 enums are exposed by the OData service as string-valued members (e.g. "PostingLayer": "Current"). Declare the property as a CLR enum and let the System.Text.Json JsonStringEnumConverter (registered globally by AddIntegratoR) handle the round-trip:

[JsonPropertyName("PostingLayer")]
public virtual CurrentOperationsTax PostingLayer { get; set; }

[JsonPropertyName("IsPosted")]
public virtual NoYes IsPosted { get; set; }

LINQ predicates against enum properties emit the qualified-type form D365 requires:

h => h.IsPosted == NoYes.Yes
// emits: $filter=IsPosted eq Microsoft.Dynamics.DataEntities.NoYes'Yes'

The qualified-type form is generated automatically by the filter translator (since v1.3.5) — for constant-literal enum comparisons in both top-level predicates and Any/All lambda bodies.

Built-in Entities

IntegratoR.OData.FO ships with two ready-made entities for the general-ledger journal flow:

Entity Composite key Notable fields
LedgerJournalHeader (DataAreaId, JournalBatchNumber) JournalName, Description, IntegrationKey, PostingLayer, IsPosted, JournalTotalDebit/Credit, AccountingCurrency
LedgerJournalLine (DataAreaId, JournalBatchNumber, LineNumber) account fields, amounts (DebitAmount/CreditAmount), dimensions, tax, payment fields

LedgerJournalLine has many fields marked [ODataField(IgnoreOnCreate = true)] because they are server-populated (AccountDisplayValue, LineNumber, TransDate, …). A minimal create needs DataAreaId, JournalBatchNumber, DebitAmount, CreditAmount, and CurrencyCode. Read the source under IntegratoR.OData.FO/Domain/Entities/LedgerJournal/ for the full attribute matrix.

IntegratoR.OData.FO.Domain.Entities.Dimensions also ships DimensionIntegrationFormat and DimensionParameters — these power the Work with Dimensions recipes.

See Also

  • Send Commands — use the entity in Create / Update / Delete operations
  • Run Queries — GetByKey uses the composite key, GetByFilter uses the LINQ-to-OData translator
  • Configure OData — the connection settings the entity is read against
  • Work with DimensionsDimensionIntegrationFormat and DimensionParameters entities

Clone this wiki locally