- Working with XrmFramework Services
In large Dynamics 365 / Dataverse projects, code reuse and testability are essential. The XrmFramework service layer is the answer to both: all data-access logic lives in typed service classes that are injected into plugins, Custom APIs, or any other consumer — never written inline.
A XrmFramework service must implement XrmFramework.IService. This marker interface signals to the framework's dependency-injection container that the type can be resolved and injected. Direct use of IOrganizationService.Execute is intentionally kept out of the public API surface; see IService Architecture for the reasoning.
Define a new interface that extends IService. Name methods after business operations, not platform primitives:
using XrmFramework;
using Microsoft.Xrm.Sdk;
using System;
namespace Contoso.Core.Services
{
public interface IAccountService : IService
{
/// <summary>Returns the canonical account number for the given account.</summary>
string GetAccountNumber(EntityReference accountRef);
/// <summary>Marks the account as a premium partner.</summary>
void PromoteToPremiumPartner(EntityReference accountRef);
/// <summary>Creates a default contact linked to the account.</summary>
void CreateDefaultContact(Guid accountId);
}
}The implementation extends DefaultService (which already satisfies IService) and provides the query and business logic. All Dataverse calls stay here — invisible to plugin code:
using XrmFramework;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Query;
using System;
namespace Contoso.Core.Services
{
public class AccountService : DefaultService, IAccountService
{
public AccountService(IServiceContext context) : base(context) { }
public string GetAccountNumber(EntityReference accountRef)
{
// The query is written once here — never duplicated in plugins.
var account = AdminOrganizationService.Retrieve(
accountRef,
new ColumnSet(AccountDefinition.Columns.AccountNumber));
return account.GetAttributeValue<string>(AccountDefinition.Columns.AccountNumber);
}
public void PromoteToPremiumPartner(EntityReference accountRef)
{
var update = new Entity(AccountDefinition.EntityName, accountRef.Id);
update[AccountDefinition.Columns.IsPremium] = true;
Update(update); // IService.Update — no raw Execute needed
}
public void CreateDefaultContact(Guid accountId)
{
var contact = new Entity("contact");
contact["parentcustomerid"] = new EntityReference(AccountDefinition.EntityName, accountId);
contact["lastname"] = "Default";
Create(contact);
}
}
}Note: The
publicaccess modifier is required on each method that satisfies the interface contract. Without it the method silently fails to implement the interface.
The framework automatically resolves any service interface declared as a method parameter. No factory, no new(), no service locator.
public void OnPreUpdate(IPluginContext context, IAccountService accountService)
{
var number = accountService.GetAccountNumber(
new EntityReference(AccountDefinition.EntityName, context.PrimaryEntityId));
context.Log("Account number: {0}", number);
}Services can be declared in any order, after IPluginContext:
public void OnPostCreate(
IPluginContext context,
IAccountService accountService,
IContactService contactService)
{
accountService.PromoteToPremiumPartner(
new EntityReference(AccountDefinition.EntityName, context.PrimaryEntityId));
contactService.SendWelcomeEmail(context.InitiatingUserId);
}The framework validates all parameters at plugin construction time, so a misconfigured service type (e.g. a missing implementation) fails fast at startup rather than at runtime.
Service injection works identically for Custom API execution methods:
public class RecomputeAccountScore : CustomApi
{
public RecomputeAccountScore() : base(nameof(Execute)) { }
[CustomApiInput(IsOptional = false)]
public CustomApiInArgument<EntityReference> AccountRef { get; set; }
[CustomApiOutput]
public CustomApiOutArgument<decimal> NewScore { get; set; }
public void Execute(ICustomApiContext context, IAccountService accountService)
{
var accountRef = context.GetArgumentValue(AccountRef);
var score = accountService.ComputeScore(accountRef, force: false);
context.SetArgumentValue(NewScore, score);
}
}DefaultService exposes several members that your implementation can use directly.
| Member | Acts as |
|---|---|
OrganizationService |
The triggering user (respects security roles) |
AdminOrganizationService |
The system administrator (bypasses security) |
Use AdminOrganizationService when you need to read data that the triggering user might not have direct access to (e.g. audit tables, configuration records). Use OrganizationService when you want to enforce row-level security on the result.
public string GetAccountNumber(EntityReference accountRef)
{
Log("GetAccountNumber called for {0}", accountRef.Id);
var account = AdminOrganizationService.Retrieve(
accountRef, new ColumnSet(AccountDefinition.Columns.AccountNumber));
var number = account.GetAttributeValue<string>(AccountDefinition.Columns.AccountNumber);
Log("GetAccountNumber result: {0}", number);
return number;
}Log writes to the Dataverse plugin trace. When using the auto-generated LoggedService wrapper, every method call is already timed and logged automatically — you only need explicit Log calls for additional internal detail.
Every IService CRUD method accepts optional impersonation flags:
// Write as the system administrator
Create(entity, useAdmin: true);
// Write as a specific user
Update(entity, callerId: ownerUserId);
// Skip downstream plugin execution for this write
Delete(entityRef, bypassCustomPluginExecution: true);The framework source generator emits a LoggedI<ServiceName> class for every service interface. This wrapper surrounds every method call with timing and structured trace output — at zero cost to the developer:
// Auto-generated — never written by hand
public class LoggedIAccountService : LoggedServiceBase, IAccountService
{
public string GetAccountNumber(EntityReference accountRef)
{
var sw = Stopwatch.StartNew();
Log(nameof(GetAccountNumber), "Start: accountRef={0}", accountRef);
var result = Service.GetAccountNumber(accountRef);
Log(nameof(GetAccountNumber), "End: duration={0} result={1}", sw.Elapsed, result);
return result;
}
// ...
}When a plugin requests IAccountService, the DI container returns a LoggedIAccountService that wraps the real AccountService. The plugin code never sees the difference.
Services are registered in the project's InternalDependencyProvider. One line wires up the implementation and the logging wrapper:
// In your project's InternalDependencyProvider
RegisterService<IAccountService, AccountService, LoggedIAccountService>(container);The source generator creates both LoggedIAccountService and the InternalDependencyProvider registration automatically when it detects a class that inherits from DefaultService.
Because service classes receive an IServiceContext through their constructor, you can supply a test double at unit-test time without touching the plugin pipeline:
// Using a fake context (e.g. from FakeXrmEasy or a hand-rolled mock)
var fakeContext = new FakeServiceContext(/* ... */);
var service = new AccountService(fakeContext);
var result = service.GetAccountNumber(
new EntityReference("account", Guid.NewGuid()));
Assert.AreEqual("ACC-001", result);For regression testing, use the Remote Debugger session replay mechanism to run a service method against a recorded real context — see RemoteDebugger.
- IService Architecture — Deep dive into the design rationale and the full
IServiceAPI surface. - Plugins — How to declare step methods and use service injection in plugins.
- Custom APIs — Service injection in Custom API execution methods.
- Remote Debugger — Debugging service calls live against a real Dataverse environment.