This solution demonstrates how to implement a single source of truth for business validation rules that can be shared between Dataverse plugins and .NET applications. The same validation logic runs in both contexts, ensuring consistency while avoiding code duplication.
- Single Source of Truth: All business rules live in one shared library
- Consistent Validation: Same rules apply whether data comes from Dataverse forms, Power Apps, Power Automate, or your API
- Fail-Fast Architecture: API validates early to provide immediate feedback, while plugins provide authoritative server-side enforcement
- Testable: Core validation logic can be unit tested in isolation using mocks
- Maintainable: Change a business rule once and it applies everywhere
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Shared.Domain Library β
β βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ β
β β Domain Models β β IOrderRulesData β β FluentValidationβ β
β β (Commands/DTOs) β β (Abstraction) β β Validators β β
β βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
βΌ βΌ
βββββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββ
β Dataverse Plugin β β ASP.NET Core API β
β β β β
β βββββββββββββββββββββββββββ β β βββββββββββββββββββββββββββ β
β β DataverseOrderRulesData β β β βDataverseOrderRulesData β β
β β (IOrganizationService) β β β β ForApp (ServiceClient) β β
β βββββββββββββββββββββββββββ β β βββββββββββββββββββββββββββ β
β β β β
β β’ PreValidation/PreOp Stage β β β’ Controller Validation β
β β’ Blocks invalid saves β β β’ Early failure (fail-fast) β
β β’ Transactional enforcement β β β’ Detailed error responses β
βββββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββ
β β
βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Dataverse β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- .NET 8.0 SDK
- Visual Studio 2022 or VS Code
- Access to a Dataverse environment
- Plugin Registration Tool (for deploying plugins)
git clone <repository-url>
cd SharedValidationExample
dotnet restore
dotnet build
The repository uses an environment-variable placeholder in appsettings.json
:
{
"ConnectionStrings": {
"Dataverse": "${DATAVERSE_CONNECTION_STRING}"
}
}
Create a user secret or environment variable:
Windows (PowerShell):
$env:DATAVERSE_CONNECTION_STRING = "AuthType=OAuth;..."
Linux/macOS:
export DATAVERSE_CONNECTION_STRING="AuthType=OAuth;..."
User Secrets (in src/Api.Orders
):
dotnet user-secrets set "ConnectionStrings:Dataverse" "AuthType=OAuth;..." --project src/Api.Orders/Api.Orders.csproj
Prefer secure flows (Client Secret or Certificate) in production; avoid username/password.
Create the following entities in your Dataverse environment:
Order Entity (new_order
):
new_customerid
(Customer lookup to Account)new_orderdate
(Date)new_ordernumber
(Text)new_totalamount
(Currency)new_productid
(Product lookup)new_quantity
(Whole Number)new_unitprice
(Currency)
- Build the
Plugins.Dataverse
project - Use Plugin Registration Tool to register:
- Assembly:
Plugins.Dataverse.dll
- Plugin:
Plugins.Dataverse.Orders.CreateOrderPlugin
- Message: Create
- Entity: new_order
- Stage: PreValidation (recommended) or PreOperation
- Mode: Synchronous
- Assembly:
cd src/Api.Orders
dotnet run
The API will be available at https://localhost:7000
with Swagger UI at the root.
Run the shared validation tests (currently 38 passing tests covering boundaries, error paths, and success cases):
cd tests/Shared.Domain.Tests
dotnet test
Test the API endpoints:
# Create an order (will validate using shared rules)
curl -X POST https://localhost:7000/api/orders \
-H "Content-Type: application/json" \
-d '{
"customerId": "customer-guid-here",
"orderNumber": "ORD-001",
"totalAmount": 20.00,
"lines": [{
"productId": "product-guid-here",
"quantity": 2,
"unitPrice": 10.00
}]
}'
# Validate without creating
curl -X POST https://localhost:7000/api/orders/validate \
-H "Content-Type: application/json" \
-d '{...same payload...}'
Create an order through:
- Dataverse forms
- Power Apps
- Power Automate
- Direct SDK calls
The same validation rules will apply and block invalid data.
The Shared.Domain
library contains:
public class CreateOrderValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderValidator(IOrderRulesData rulesData)
{
// All business rules defined here
RuleFor(x => x.CustomerId)
.NotEmpty()
.MustAsync(async (id, ct) => await rulesData.CustomerExistsAsync(id, ct))
.WithMessage("Customer does not exist.");
// ... more rules
}
}
public void Execute(IServiceProvider serviceProvider)
{
// Get Dataverse services
var service = GetOrganizationService(serviceProvider);
var target = GetTargetEntity(context);
// Map to domain command
var command = EntityMapper.MapToCreateOrderCommand(target);
// Use shared validation
var validator = new CreateOrderValidator(new DataverseOrderRulesData(service));
var result = validator.Validate(command);
if (!result.IsValid)
{
// Block the save
throw new InvalidPluginExecutionException(result.GetErrorsAsString());
}
}
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
var command = request.ToCommand();
// Same validator, different data adapter
var result = await _validator.ValidateAsync(command);
if (!result.IsValid)
{
// Return 400 with detailed errors
return ValidationProblem(result.ToErrorDictionary());
}
// Create the order (plugin will validate again as final guard)
var response = await _orderService.CreateOrderAsync(command);
return CreatedAtAction(nameof(GetOrderById), new { id = response.OrderId }, response);
}
- Update the validator in
Shared.Domain/Orders/CreateOrderValidator.cs
- Add any new data requirements to
IOrderRulesData
- Implement in both adapters (plugin and API)
- Add unit tests in
Shared.Domain.Tests
- Create new command models (e.g.,
CreateCustomerCommand
) - Create new validators (e.g.,
CreateCustomerValidator
) - Create new adapters for data access
- Create new plugins and controllers
- Async validation: Already supported via
MustAsync
in FluentValidation - Cross-entity validation: Implement in
IOrderRulesData
adapters - Conditional validation: Use FluentValidation's
When
conditions - Custom validation: Implement
CustomAsync
rules for complex logic
- Use PreValidation stage when possible (cheaper than PreOperation rollbacks)
- Keep plugins fast - avoid heavy computations or external calls
- Use proper error handling - return user-friendly messages
- Include tracing for debugging
- Filter attributes in plugin steps to avoid unnecessary executions
- Validate early in controllers before expensive operations
- Return structured errors using ValidationProblemDetails
- Use async patterns throughout the validation chain
- Implement proper logging for troubleshooting
- Consider caching for frequently accessed reference data
- Mock
IOrderRulesData
for isolated unit tests - Test edge cases thoroughly (null values, boundary conditions)
- Integration test both paths (plugin and API) against actual Dataverse
- Performance test with realistic data volumes
Shared.Domain
targets both net8.0
and netstandard2.0
so it can be consumed by:
- Modern .NET 8 API (leveraging latest runtime features)
- Legacy
net472
plugin vianetstandard2.0
surface (compatible with classic Dataverse plugin host)
Records are preserved by adding an IsExternalInit
polyfill for the netstandard2.0
target, avoiding refactors to classes while keeping modern C# expressiveness.
File | Purpose |
---|---|
Shared.Domain/Orders/CreateOrderValidator.cs |
Core validation logic - single source of truth for all business rules |
Shared.Domain/Orders/IOrderRulesData.cs |
Data access abstraction - defines what data validators need |
Plugins.Dataverse/Orders/CreateOrderPlugin.cs |
Plugin implementation - enforces validation in Dataverse pipeline |
Api.Orders/Controllers/OrdersController.cs |
API controller - validates before sending to Dataverse |
Plugins.Dataverse/Adapters/DataverseOrderRulesData.cs |
Plugin data adapter - implements IOrderRulesData using IOrganizationService |
Api.Orders/Adapters/DataverseOrderRulesDataForApp.cs |
API data adapter - implements IOrderRulesData using ServiceClient |
Tests/Shared.Domain.Tests/CreateOrderValidatorAdditionalTests.cs |
Extended test coverage for boundaries & edge cases |
Do not commit real credentials. Connection string is externalized. Recommended improvements:
- Use Azure Key Vault or environment variables in hosting environment
- Store secrets only in CI secret store (GitHub Actions Secrets)
- Enforce HTTPS and strict TLS settings
- Add static code analysis (CodeQL)
Workflow .github/workflows/ci.yml
runs on pushes and PRs to main
/ master
:
- Restore -> Build (Release) -> Test (with coverage collection)
- Publishes test results & coverage (Cobertura + lcov) as artifacts
- Optional Codecov upload step (add
CODECOV_TOKEN
secret to enable) - Separate job lints
README.md
Additional automation:
- Code scanning via CodeQL (
.github/workflows/codeql.yml
) - Dependency updates via Dependabot (
.github/dependabot.yml
) for NuGet & GitHub Actions (daily)
Recommended next enhancements:
- Enforce status checks (tests, CodeQL) before merge
- Add branch protection & required reviews
- Gate plugin deployment with a manual approval job
- Implement real order line entity persistence & retrieval
- Add credit limit validation using customer financials
- Introduce caching layer for product/customer lookups
- Provide integration test project hitting a Dataverse sandbox (flagged to skip in CI without env vars)
- Add OpenAPI schema filtering for validation error codes
- Provide sample Power Automate flow invoking API
If environment variable not picked up:
- Ensure terminal session set it before running
dotnet run
- On Windows, consider using System Environment Variables if launching via IDE
If plugin cannot load assembly:
- Confirm target framework remains
net472
- Ensure no accidental reference to
net8.0
-only APIs in plugin project
If validation differs between API and plugin:
- Check both adapters (
DataverseOrderRulesData
vsDataverseOrderRulesDataForApp
) return matching data for same IDs
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Ensure all tests pass
- Submit a pull request
This project is licensed under the MIT License - see the LICENSE file for details.
Plugin not firing: Check plugin registration, message, entity, and stage configuration.
Connection string errors: Verify Dataverse URL, credentials, and AppId in connection string.
Validation not working: Ensure both adapters implement IOrderRulesData correctly and return consistent results.
Performance issues:
- Use column sets to limit data retrieval
- Implement caching for reference data
- Avoid N+1 query patterns
- Check the unit tests for usage examples
- Review the Swagger documentation at the API root
- Enable detailed logging to troubleshoot validation failures
- Use Dataverse tracing to debug plugin execution