-
Notifications
You must be signed in to change notification settings - Fork 1
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].
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:
-
[Table("LedgerJournalHeaders")]maps the class to the D365 OData entity set. The name is case-sensitive and almost always plural —LedgerJournalHeaderis the entity,LedgerJournalHeadersis the entity set. -
BaseEntity<TKey>declares the abstractGetCompositeKey()that handlers use for composite-key URL construction and reflection-based structured logging viaGetLoggingContext(). -
[Key]plusGetCompositeKey()define which properties form the primary key and the order in which the framework passes them to OData reads.
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.
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.
[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.
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.
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.
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'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.
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.
- 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 Dimensions —
DimensionIntegrationFormatandDimensionParametersentities
Get Started
Use Cases
- Handle Errors
- Configure Resilience
- Add Validation
- Cache Query Results
- Work with Dimensions
- Run Smoke Tests
- Integrate with RELion
- Extend the Pipeline
- Test with TestKit
Reference
- Understand the Architecture
- Authentication Modes
- Set Up Azure Functions Host
- Known Limitations
- Troubleshoot Common Issues
- Release Notes and Versioning