-
Notifications
You must be signed in to change notification settings - Fork 1
Send Commands
Commands modify D365 F&O state via OData create (POST), update (PATCH), and delete (DELETE) requests. The framework provides generic commands that work with any entity implementing IEntity — custom command types are only required when entity-specific logic (logging context, defaults, chained operations) is needed.
LedgerJournalHeader header = new()
{
DataAreaId = "USMF",
JournalName = "GenJrn",
Description = "Monthly accruals"
};
Result<LedgerJournalHeader> result = await mediator.Send(
new CreateCommand<LedgerJournalHeader>(header),
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
{
string batchNumber = result.Value.JournalBatchNumber!; // server-assigned
}CreateCommand<TEntity> is a record with a positional constructor taking the entity to create. The handler:
- Reads
[ODataField(IgnoreOnCreate)]andAllowEditOnCreate = falseannotations and strips matching properties from the payload. - Serialises the remaining properties using the entity's
[JsonPropertyName]mappings. - POSTs to
<ODataSettings.Url>/<TableName>whereTableNamecomes from[Table(...)]. - Deserialises the response into a fresh
TEntity(with server-generated fields populated) and wraps it inResult.Ok(...).
header.Description = "Updated description";
Result<LedgerJournalHeader> result = await mediator.Send(
new UpdateCommand<LedgerJournalHeader>(header),
cancellationToken).ConfigureAwait(false);UpdateCommand<TEntity> sends a PATCH to the composite-key URL. Fields marked IgnoreOnUpdate or with AllowEdit = false are excluded from the payload. The composite key on the entity must be fully populated — the framework reads GetCompositeKey() to build the key segment.
The composite-key URL for the update path has a known limitation in the current PanoramicData.OData.Client release — see Known Limitations for the parked workaround design. Reads via
GetByKeyQuery<T>use a different code path that already handles composite keys via theFilter()bypass.
Result<LedgerJournalHeader> result = await mediator.Send(
new DeleteCommand<LedgerJournalHeader>(header),
cancellationToken).ConfigureAwait(false);The composite key on the entity must be populated. The same composite-key write limitation noted above applies.
Batch commands send the same operation against a collection of entities. Each entity is processed individually — if one fails, the result captures the error but earlier successes are not rolled back (D365's OData surface does not expose a transactional batch endpoint at the entity-set level).
List<LedgerJournalLine> lines =
[
new()
{
DataAreaId = "USMF",
JournalBatchNumber = "JBN-000431",
DebitAmount = 1000m,
CreditAmount = 0m,
CurrencyCode = "USD"
},
new()
{
DataAreaId = "USMF",
JournalBatchNumber = "JBN-000431",
DebitAmount = 0m,
CreditAmount = 1000m,
CurrencyCode = "USD"
}
];
Result result = await mediator.Send(
new CreateBatchCommand<LedgerJournalLine>(lines),
cancellationToken).ConfigureAwait(false);Batch commands return non-generic Result (no per-entity values on success). The three batch shapes:
| Command | Effective HTTP method | Use case |
|---|---|---|
CreateBatchCommand<TEntity> |
POST per entity | Bulk insert |
UpdateBatchCommand<TEntity> |
PATCH per entity | Bulk update |
DeleteBatchCommand<TEntity> |
DELETE per entity | Bulk delete |
For high-volume work the consumer can parallelise by splitting the collection and sending multiple batches concurrently. The OData client respects the Polly circuit breaker — concurrent batches against a degraded D365 environment fail fast rather than amplifying load.
Every command passes through the MediatR pipeline in the registration order:
-
LoggingBehaviour— logs request type, duration, andIContext-derived structured properties (entity type, composite key, etc.). -
ValidationBehaviour— runs registered FluentValidation validators; returnsResult.Fail(IntegrationError(... type: Validation))and short-circuits the pipeline on the first failure. -
CachingBehaviour— short-circuits to cache hit when the request implementsICacheableQuery<T>. Commands are neverICacheableQuery, so this is a pass-through. -
Handler — the closed-generic
CreateCommandHandler<TEntity>/UpdateCommandHandler<TEntity>/DeleteCommandHandler<TEntity>callsODataService<TEntity>to execute the HTTP operation.
The Polly retry and circuit breaker live below this pipeline at the HttpClient layer — wrapped in the DelegatingHandler chain inside AddODataClient. Retries are transparent to the MediatR pipeline.
When entity-specific logic is needed (custom logging context, default values, pre-validation, chained operations), define a record that implements ICommand<TResponse>:
using FluentResults;
using IntegratoR.Abstractions.Interfaces.Commands;
public record PostJournalCommand(string DataAreaId, string JournalBatchNumber)
: ICommand<Result>;Pair it with a handler that implements IRequestHandler<PostJournalCommand, Result>:
public sealed class PostJournalCommandHandler
: IRequestHandler<PostJournalCommand, Result>
{
private readonly IService<LedgerJournalHeader> _service;
private readonly ILogger<PostJournalCommandHandler> _logger;
public PostJournalCommandHandler(
IService<LedgerJournalHeader> service,
ILogger<PostJournalCommandHandler> logger)
{
_service = service;
_logger = logger;
}
public async Task<Result> Handle(PostJournalCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Posting journal {DataAreaId}/{BatchNumber}",
request.DataAreaId, request.JournalBatchNumber);
// Custom logic: invoke a D365 OData bound action, chain a status update, etc.
return Result.Ok();
}
}Register the handler's assembly through AddConsumerHandlers(...) and the pipeline picks it up automatically.
For a non-state-returning command (e.g. fire-and-forget) use the non-generic ICommand interface:
public record ArchiveJournalCommand(string DataAreaId, string JournalBatchNumber)
: ICommand;
// handler returns Task<Result>, no payloadIntegratoR.OData.FO ships entity-specific command types as examples — they inherit from the generic commands and route to dedicated handlers that add ledger-specific logging context:
-
CreateLedgerJournalHeaderCommand<T>/CreateLedgerJournalHeadersCommand<T>(plural variant for batch) -
CreateLedgerJournalLineCommand<T>/CreateLedgerJournalLinesCommand<T> -
UpdateLedgerJournalHeaderCommand<T>/UpdateLedgerJournalHeadersCommand<T> -
UpdateLedgerJournalLineCommand<T>/UpdateLedgerJournalLinesCommand<T>
For most consumers the generic CreateCommand<LedgerJournalHeader> is enough — pick the entity-specific variant only when the entity-specific log context is genuinely useful.
Result<LedgerJournalHeader> result = await mediator.Send(command, cancellationToken)
.ConfigureAwait(false);
if (result.IsFailed)
{
IntegrationError? error = result.GetError();
return error?.Type switch
{
ErrorType.Validation => HttpStatusCode.BadRequest,
ErrorType.NotFound => HttpStatusCode.NotFound,
ErrorType.Conflict => HttpStatusCode.Conflict,
_ => HttpStatusCode.InternalServerError
};
}See Handle Errors for the full error model, Match pattern matching, and how to surface errors back to HTTP callers.
-
Define Entities —
[ODataField],[JsonPropertyName], composite keys - Run Queries — fetch records before updating or deleting
-
Handle Errors — process the
Result<T>returned by every command - Add Validation — pre-flight checks via FluentValidation in the pipeline
- Extend the Pipeline — custom MediatR behaviours
- Known Limitations — composite-key Update/Delete workaround status
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