-
Notifications
You must be signed in to change notification settings - Fork 1
Getting Started
This guide walks through adding IntegratoR to a fresh Azure Functions isolated-worker project and sending a first command to D365 Finance & Operations. The walkthrough takes about ten minutes and produces a project that can read and write live data.
Prerequisites
- .NET 10 SDK with the
previewquality channel installed- An Azure Functions isolated-worker project (the in-process model is not supported)
- A D365 F&O environment with an Azure AD app registration that has OData access
- Either an OAuth client secret or an Azure API Management subscription key
A single NuGet reference brings in the full framework:
dotnet add package IntegratoR.HostingIntegratoR.Hosting is the composition root. It pulls in IntegratoR.Application, IntegratoR.OData, IntegratoR.OData.FO, and IntegratoR.Abstractions as transitive dependencies, along with MediatR, FluentResults, FluentValidation, and Polly. The optional IntegratoR.RELion package is installed separately when the integration also needs to talk to RELion — see Integrate with RELion.
Add an ODataSettings section to local.settings.json (or application settings in Azure). The structure is nested — connection settings at the root, authentication under Authentication, and resilience under Resilience.
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated"
},
"ODataSettings": {
"Url": "https://your-environment.operations.dynamics.com/data",
"Authentication": {
"Mode": "OAuth",
"OAuth": {
"ClientId": "<azure-ad-app-registration-client-id>",
"ClientSecret": "<client-secret>",
"TenantId": "<azure-ad-tenant-id>",
"Resource": "https://your-environment.operations.dynamics.com"
}
}
}
}The Mode property defaults to ApiKey for APIM-fronted environments. Direct service-to-service calls to D365 use OAuth and require the four OAuth.* fields. See Configure OData for the full settings reference, and Authentication Modes for the API Management alternative.
On Azure App Settings, JSON nesting is expressed with double underscores:
ODataSettings__Authentication__OAuth__ClientId. TheModefield accepts the string"OAuth"or"ApiKey".
The minimal Program.cs for an isolated-worker host:
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var host = new HostBuilder()
.ConfigureFunctionsWorkerDefaults()
.ConfigureServices((context, services) =>
{
Assembly clientAssembly = Assembly.GetExecutingAssembly();
services.AddIntegratoR(context.Configuration, integrator =>
{
integrator.AddConsumerHandlers(clientAssembly);
});
})
.Build();
host.Run();AddIntegratoR is the single composition entry point. The call registers the Application layer (MediatR pipeline behaviours in the order Logging → Validation → Caching → Handler, the cache service, the OAuth authenticator), the generic OData HTTP client with Polly resilience policies, and the D365 F&O entity handlers. AddConsumerHandlers(...) scans the supplied assembly for MediatR handlers and FluentValidation validators defined in the consumer project.
A production-ready host that also wires Azure Key Vault, Application Insights, and the Newtonsoft Result<T> converter is shown in Set Up Azure Functions Host.
D365 F&O entities are mapped to plain C# classes that derive from BaseEntity<TKey>:
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)] // server-generated by D365 number sequence
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 things make this work end-to-end:
-
[Table("LedgerJournalHeaders")]maps the class to the OData entity set in D365 F&O. -
BaseEntity<TKey>declares the abstractGetCompositeKey()the framework uses for composite-key URL construction and structured logging. -
[ODataField(IgnoreOnCreate = true)]excludes the server-generatedJournalBatchNumberfrom the POST payload — the value is populated on the entity returned from D365 after creation.
D365 F&O's wire format uses camelCase for legacy X++ system fields (e.g. dataAreaId) and PascalCase for everything else. The [JsonPropertyName] attributes pin the wire name explicitly. See Define Entities for the full attribute reference.
This particular entity is also bundled in IntegratoR.OData.FO, ready for immediate use — using IntegratoR.OData.FO.Domain.Entities.LedgerJournal;.
Inject IMediator into an Azure Function and send a CreateCommand<T>:
using FluentResults;
using IntegratoR.Abstractions.Common.CQRS.Commands;
using IntegratoR.Abstractions.Common.Results;
using IntegratoR.OData.FO.Domain.Entities.LedgerJournal;
using MediatR;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using System.Net;
public sealed class CreateJournalFunction(IMediator mediator, ILogger<CreateJournalFunction> logger)
{
[Function("CreateJournal")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req,
CancellationToken cancellationToken)
{
LedgerJournalHeader header = new()
{
DataAreaId = "USMF",
JournalName = "GenJrn",
Description = "Monthly accruals — March 2026"
};
Result<LedgerJournalHeader> result = await mediator.Send(
new CreateCommand<LedgerJournalHeader>(header),
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
{
logger.LogInformation(
"Created journal {BatchNumber}",
result.Value.JournalBatchNumber);
HttpResponseData ok = req.CreateResponse(HttpStatusCode.Created);
await ok.WriteAsJsonAsync(result.Value, cancellationToken).ConfigureAwait(false);
return ok;
}
IntegrationError? error = result.GetError();
logger.LogWarning(
"Create failed: [{Code}] {Message}",
error?.Code, error?.Message);
HttpResponseData fail = req.CreateResponse(HttpStatusCode.BadRequest);
await fail.WriteAsJsonAsync(new { error?.Code, error?.Message }, cancellationToken).ConfigureAwait(false);
return fail;
}
}Every framework operation returns Result<T> from FluentResults. Business-level errors never throw — the consumer inspects result.IsSuccess and reads the typed IntegrationError via result.GetError(). See Handle Errors for the full error model.
When mediator.Send(...) was called, the request passed through the MediatR pipeline in this order:
-
LoggingBehaviourlogged the command type and started a duration measurement. -
ValidationBehaviourran any registered FluentValidation validators forCreateCommand<LedgerJournalHeader>; with no validators registered, this is a pass-through. -
CachingBehaviourchecked for anICacheableQuery<T>marker (commands are never cached, so this is a pass-through). -
CreateCommandHandler<LedgerJournalHeader>calledODataService<LedgerJournalHeader>.AddAsync(...), which serialised the entity (excludingJournalBatchNumberbecause of[ODataField(IgnoreOnCreate = true)]) and POSTed it tohttps://your-environment.operations.dynamics.com/data/LedgerJournalHeaders.
Before the request reached D365:
- The
ODataAuthenticationHandleracquired an OAuth bearer token via MSAL and added it as theAuthorizationheader. Tokens are cached for the duration of their lifetime with a 5-minute proactive refresh window. - The Polly retry policy wrapped the call. If D365 returned 408, 429, or 5xx, the call would have been retried with exponential backoff plus jitter (default: 3 attempts).
- The Polly circuit breaker monitored consecutive transient failures. After 5 in a row it would open for 30 seconds, failing all subsequent requests fast.
The response was deserialised into a fresh LedgerJournalHeader (with the server-assigned JournalBatchNumber populated) and wrapped in Result.Ok(...). The handler returned, behaviours unwound (logging the success and duration), and the function got back the Result<T>.
"No service for type IRequestHandler<CreateCommand<LedgerJournalHeader>, Result<LedgerJournalHeader>>": the entity type is not visible to the MediatR closed-generic registration. Either the entity lives in IntegratoR.OData.FO (in which case AddIntegratoR already handles it) or the consumer needs to call integrator.AddConsumerHandlers(typeof(MyEntity).Assembly) for the assembly that contains the custom entity.
result.IsFailed with error.Code == "OData.AuthenticationFailed": the OAuth credentials are wrong, expired, or the service principal lacks API access on the D365 environment. Verify ClientId, ClientSecret, TenantId, and Resource in ODataSettings.Authentication.OAuth. Secrets expire — production deployments should source the secret from Azure Key Vault as shown in Set Up Azure Functions Host.
HttpRequestException at host startup mentioning a relative URL: ODataSettings.Url is missing, empty, or not a fully-qualified absolute URL. The framework throws an ArgumentException at DI resolution time with a clear message — re-check the configuration source.
HTTP 404 from D365 with a malformed key segment: the entity's [Table(...)] attribute references the wrong entity set name (singular vs plural is a common cause — D365 entity sets are plural). See Troubleshoot Common Issues.
The repository ships two HTTP triggers in IntegratoR.SampleFunction that exercise the full pipeline against a real D365 sandbox — useful for verifying that a fresh setup actually works:
-
POST /api/smoke/ledger-journalexercises create/get/filter/update onLedgerJournalHeaderandLedgerJournalLine. -
POST /api/smoke/financial-dimensionsrunsGetDimensionOrdersQueryagainst the dimension metadata entities.
Both are read-only (financial-dimensions) or self-cleaning (ledger-journal best-effort) and return a per-step JSON breakdown. See Run Smoke Tests for request bodies and expected responses.
- Configure OData — full settings reference
-
Define Entities —
BaseEntity<TKey>, attributes, composite keys - Send Commands — Create / Update / Delete plus batch
- Run Queries — GetByKey and GetByFilter
- Set Up Azure Functions Host — production wiring
- Troubleshoot Common Issues — real errors and resolutions
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