Showing posts with label csharp. Show all posts
Showing posts with label csharp. Show all posts

Tuesday, 20 January 2026

Dataverse CRUD with a .NET 8 Console App using Managed Identity, DI, and Polly

Dataverse CRUD with a .NET 8 console app: this guide shows how to build a production-ready C# application that performs create, read, update, and delete operations against Microsoft Dataverse using Dependency Injection, DefaultAzureCredential, resilient retries (Polly), and clean configuration. You get copy-pasteable code, least-privilege security guidance, and optional Azure Container Apps + Key Vault deployment via azd/Bicep.

The Problem

You need a reliable way to run Dataverse CRUD from a console app without hardcoded secrets, avoiding static helpers, and ensuring the code is cloud-ready and testable. Many examples skip DI, retries, and security, leading to brittle apps.

Prerequisites

.NET 8 SDK, Azure CLI v2.58+, Azure Developer CLI (azd) v1.9+, Power Platform tooling access to create an Application User in Dataverse mapped to your app's Service Principal or Managed Identity, A Dataverse environment URL (e.g., https://yourorg.crm.dynamics.com)

The Solution (Step-by-Step)

1) Project setup and configuration

Create a new console app and add packages.

// Bash: create project and add packages
 dotnet new console -n DataverseCrud.Console --framework net8.0
 cd DataverseCrud.Console
 dotnet add package Microsoft.PowerPlatform.Dataverse.Client --version 1.1.27
 dotnet add package Azure.Identity --version 1.11.3
 dotnet add package Polly.Extensions.Http --version 3.0.0
 dotnet add package Microsoft.Extensions.Http.Polly --version 8.0.1

Add appsettings.json for configuration (no secrets).

{
  "Dataverse": {
    "OrganizationUrl": "https://yourorg.crm.dynamics.com",
    "ApiVersion": "v9.2", 
    "DefaultTableLogicalName": "account"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning"
    }
  }
}

Pro-Tip: Keep ApiVersion configurable so you can upgrade Dataverse API without code changes.

2) Implement the Generic Host with DI, typed options, and retry policies

Program.cs uses file-scoped namespaces, DI, and structured logging via Microsoft.Extensions.Logging. Polly adds resilient retries for transient failures.

using System.Net.Http.Headers;
using Azure.Core;
using Azure.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Polly;
using Polly.Extensions.Http;
using Microsoft.PowerPlatform.Dataverse.Client;

namespace DataverseCrud.ConsoleApp;

// Options record for strongly-typed config
public sealed record DataverseOptions(string OrganizationUrl, string ApiVersion, string DefaultTableLogicalName);

// Abstraction for testability
public interface IDataverseRepository
{
    // CRUD signatures using basic types for clarity
    Task<Guid> CreateAsync(string tableLogicalName, IDictionary<string, object> attributes, CancellationToken ct);
    Task<Dictionary<string, object>?> RetrieveAsync(string tableLogicalName, Guid id, string columnsCsv, CancellationToken ct);
    Task UpdateAsync(string tableLogicalName, Guid id, IDictionary<string, object> attributes, CancellationToken ct);
    Task DeleteAsync(string tableLogicalName, Guid id, CancellationToken ct);
}

// Concrete repository using ServiceClient
public sealed class DataverseRepository(IOptions<DataverseOptions> options, ILogger<DataverseRepository> logger, ServiceClient serviceClient) : IDataverseRepository
{
    private readonly DataverseOptions _opt = options.Value;
    private readonly ILogger<DataverseRepository> _logger = logger;
    private readonly ServiceClient _client = serviceClient;

    // Create a row in Dataverse
    public async Task<Guid> CreateAsync(string tableLogicalName, IDictionary<string, object> attributes, CancellationToken ct)
    {
        // Convert to Entity representation
        var entity = new Microsoft.Xrm.Sdk.Entity(tableLogicalName);
        foreach (var kvp in attributes)
            entity.Attributes[kvp.Key] = kvp.Value;

        var id = await _client.CreateAsync(entity, ct).ConfigureAwait(false);
        _logger.LogInformation("Created {Table} record with Id {Id}", tableLogicalName, id);
        return id;
    }

    // Retrieve a row with selected columns
    public async Task<Dictionary<string, object>?> RetrieveAsync(string tableLogicalName, Guid id, string columnsCsv, CancellationToken ct)
    {
        var columns = new Microsoft.Xrm.Sdk.Query.ColumnSet(columnsCsv.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
        var entity = await _client.RetrieveAsync(tableLogicalName, id, columns, ct).ConfigureAwait(false);
        if (entity == null)
        {
            _logger.LogWarning("Entity not found: {Table}/{Id}", tableLogicalName, id);
            return null;
        }
        return entity.Attributes.ToDictionary(k => k.Key, v => v.Value);
    }

    // Update specific attributes
    public async Task UpdateAsync(string tableLogicalName, Guid id, IDictionary<string, object> attributes, CancellationToken ct)
    {
        var entity = new Microsoft.Xrm.Sdk.Entity(tableLogicalName) { Id = id };
        foreach (var kvp in attributes)
            entity.Attributes[kvp.Key] = kvp.Value;

        await _client.UpdateAsync(entity, ct).ConfigureAwait(false);
        _logger.LogInformation("Updated {Table} record {Id}", tableLogicalName, id);
    }

    // Delete a row
    public async Task DeleteAsync(string tableLogicalName, Guid id, CancellationToken ct)
    {
        await _client.DeleteAsync(tableLogicalName, id, ct).ConfigureAwait(false);
        _logger.LogInformation("Deleted {Table} record {Id}", tableLogicalName, id);
    }
}

public class Program
{
    public static async Task Main(string[] args)
    {
        using var host = Host.CreateApplicationBuilder(args);

        // Bind options from configuration
        host.Services.AddOptions<DataverseOptions>()
            .Bind(host.Configuration.GetSection("Dataverse"))
            .ValidateDataAnnotations()
            .Validate(opt => !string.IsNullOrWhiteSpace(opt.OrganizationUrl) && !string.IsNullOrWhiteSpace(opt.ApiVersion));

        host.Services.AddLogging(builder =>
        {
            builder.AddSimpleConsole(o =>
            {
                o.TimestampFormat = "yyyy-MM-dd HH:mm:ss ";
                o.SingleLine = true;
            });
        });

        // Configure resilient HTTP with Polly for Dataverse traffic
        host.Services.AddHttpClient("dataverse")
            .SetHandlerLifetime(TimeSpan.FromMinutes(5))
            .AddPolicyHandler(GetRetryPolicy());

        // Register ServiceClient using DefaultAzureCredential (prefers Managed Identity in Azure)
        host.Services.AddSingleton(sp =>
        {
            var opts = sp.GetRequiredService<IOptions<DataverseOptions>>().Value;
            var credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions
            {
                // In production on Azure Container Apps/VMs, Managed Identity will be used.
                // Locally, DeveloperCredential/VisualStudioCredential will be used.
                ExcludeManagedIdentityCredential = false
            });

            var svc = new ServiceClient(new Uri(opts.OrganizationUrl), credential, useUniqueInstance: true, useWebApi: true);
            // Configure ServiceClient API version if supported/necessary by environment
            svc.SdkVersion = opts.ApiVersion; // Documented: keep version configurable
            return svc;
        });

        host.Services.AddSingleton<IDataverseRepository, DataverseRepository>();

        var app = host.Build();

        // Execute a CRUD flow
        var logger = app.Services.GetRequiredService<ILogger<Program>>();
        var repo = app.Services.GetRequiredService<IDataverseRepository>();
        var cfg = app.Services.GetRequiredService<IOptions<DataverseOptions>>().Value;

        using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));

        try
        {
            // 1) Create
            var accountId = await repo.CreateAsync(cfg.DefaultTableLogicalName, new Dictionary<string, object>
            {
                ["name"] = "Contoso (Console App)",
                ["telephone1"] = "+1-425-555-0100"
            }, cts.Token);

            // 2) Retrieve
            var data = await repo.RetrieveAsync(cfg.DefaultTableLogicalName, accountId, "name,telephone1", cts.Token);
            logger.LogInformation("Retrieved: {Data}", string.Join("; ", data!.Select(kv => $"{kv.Key}={kv.Value}")));

            // 3) Update
            await repo.UpdateAsync(cfg.DefaultTableLogicalName, accountId, new Dictionary<string, object>
            {
                ["telephone1"] = "+1-425-555-0101"
            }, cts.Token);

            // 4) Delete
            await repo.DeleteAsync(cfg.DefaultTableLogicalName, accountId, cts.Token);

            logger.LogInformation("CRUD flow completed successfully.");
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Failure during CRUD flow");
            Environment.ExitCode = 1;
        }

        await app.StopAsync();
    }

    // Exponential backoff for transient HTTP 5xx/429 responses
    private static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy() =>
        HttpPolicyExtensions
            .HandleTransientHttpError()
            .OrResult(r => (int)r.StatusCode == 429)
            .WaitAndRetryAsync(5, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
}

Pro-Tip: Add structured properties to logs (e.g., table, id) for easier querying in Application Insights.

3) Dataverse authentication without secrets

The ServiceClient uses DefaultAzureCredential, which in Azure will use Managed Identity. In local development, it falls back to developer credentials. You must create an Application User in Dataverse mapped to the Service Principal or Managed Identity and assign the least-privilege Dataverse security role needed for CRUD on targeted tables.

Pro-Tip: Scope Dataverse privileges to only the tables and operations your app requires.

4) Optional: Run in Azure Container Apps with Managed Identity (IaC via azd/Bicep)

If you want to run this console app on a schedule or on-demand in Azure, deploy to Azure Container Apps with a System-Assigned Managed Identity. Use Key Vault to hold non-secret config like OrganizationUrl and ApiVersion for central governance.

// azure.yaml for azd (simplified)
name: dataverse-crud
metadata:
  template: dataverse-crud
services:
  app:
    project: ./
    language: dotnet
    host: containerapp
    docker:
      path: ./Dockerfile
      context: ./

// main.bicep (simplified): Container App + Key Vault + MI and RBAC
param location string = resourceGroup().location
param appName string = 'dataverse-crud-app'
param kvName string = 'dvcrud-kv-${uniqueString(resourceGroup().id)}'

resource kv 'Microsoft.KeyVault/vaults@2023-07-01' = {
  name: kvName
  location: location
  properties: {
    tenantId: subscription().tenantId
    sku: { family: 'A', name: 'standard' }
    accessPolicies: [] // Use RBAC rather than access policies
    enableRbacAuthorization: true
  }
}

resource caEnv 'Microsoft.App/managedEnvironments@2023-05-01' = {
  name: 'dvcrud-env'
  location: location
}

resource app 'Microsoft.App/containerApps@2023-05-01' = {
  name: appName
  location: location
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    managedEnvironmentId: caEnv.id
    template: {
      containers: [
        {
          name: 'app'
          image: 'ghcr.io/yourrepo/dataverse-crud:latest'
          env: [
            // Pull configuration from Key Vault via secrets or set as plain env if non-secret
            { name: 'Dataverse__OrganizationUrl', value: 'https://yourorg.crm.dynamics.com' },
            { name: 'Dataverse__ApiVersion', value: 'v9.2' },
            { name: 'Dataverse__DefaultTableLogicalName', value: 'account' }
          ]
        }
      ]
    }
  }
}

// Grant the Container App's Managed Identity access to Key Vault secrets if used for configuration
resource kvAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(kv.id, 'kv-secrets-user', app.name)
  scope: kv
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') // Key Vault Secrets User
    principalId: app.identity.principalId
    principalType: 'ServicePrincipal'
  }
}

Azure RBAC roles used here: Key Vault Secrets User on the vault to read secrets if you store any configuration as secrets. The Container App requires no additional Azure RBAC to call Dataverse; it authenticates via its Managed Identity to Entra ID, and Dataverse access is governed by the Application User and Dataverse security role assignments.

Pro-Tip: Prefer System-Assigned Managed Identity for lifecycle simplicity unless you need identity reuse across apps, in which case use a User-Assigned Managed Identity.

5) Testing and mocking

Abstract your repository behind IDataverseRepository to unit test business logic without hitting Dataverse. For integration tests, use a test environment with a restricted Dataverse security role.

// Example unit test using a fake repository
public sealed class FakeRepo : IDataverseRepository
{
    public List<string> Calls { get; } = new();
    public Task<Guid> CreateAsync(string t, IDictionary<string, object> a, CancellationToken ct) { Calls.Add("create"); return Task.FromResult(Guid.NewGuid()); }
    public Task<Dictionary<string, object>?> RetrieveAsync(string t, Guid id, string cols, CancellationToken ct) { Calls.Add("retrieve"); return Task.FromResult<Dictionary<string, object>?>(new()); }
    public Task UpdateAsync(string t, Guid id, IDictionary<string, object> a, CancellationToken ct) { Calls.Add("update"); return Task.CompletedTask; }
    public Task DeleteAsync(string t, Guid id, CancellationToken ct) { Calls.Add("delete"); return Task.CompletedTask; }
}

Pro-Tip: Use dependency injection to pass a fake or mock implementation into services that depend on IDataverseRepository.

Best Practices & Security

Authentication and secrets: Use DefaultAzureCredential. Do not embed client secrets or connection strings. In Azure, rely on Managed Identity. Locally, authenticate via developer credentials.

Dataverse permissions: Assign a least-privilege Dataverse security role to the Application User (e.g., CRUD on specific tables only). Avoid granting environment-wide privileges.

Azure RBAC: If using Key Vault, grant only the Key Vault Secrets User role to the app’s Managed Identity. Do not grant Owner or Contributor without justification.

Resilience: The Polly retry policy handles 5xx/429 responses with exponential backoff; tune retry counts/timeouts per SLOs.

Observability: Use Microsoft.Extensions.Logging with structured state for IDs and table names. Forward logs to Application Insights via the built-in provider when running in Azure.

Configuration hygiene: Keep OrganizationUrl, ApiVersion, and default table logical names in configuration. Validate options at startup.

Token reuse: DefaultAzureCredential and underlying token providers cache tokens. Avoid recreating credentials per request; register ServiceClient as a singleton as shown.

Pro-Tip: For read-only operations at scale, prefer the Web API with select columns and server-side paging; avoid retrieving full records when not needed.

Summary

• You built a .NET 8 console app that performs Dataverse CRUD using DI, DefaultAzureCredential, and Polly retries.

• Security is hardened with Managed Identity, least-privilege Dataverse roles, and optional Azure RBAC for Key Vault.

• The solution is production-ready, testable, and configurable, with an optional path to deploy on Azure Container Apps via azd/Bicep.

Next step: map your Managed Identity or app registration to a Dataverse Application User, assign a minimal security role, and run the CRUD flow against your target tables.


Thursday, 15 January 2026

Const vs readonly in C#: Practical Rules, .NET 8 Examples, and When to Use Each

Const vs readonly in C#: use const for compile-time literals that never change and readonly for runtime-initialized values that should not change after construction. This article shows clear rules, .NET 8 examples with Dependency Injection, and production considerations so you pick the right tool every time.

The Problem

Mixing const and readonly without intent leads to brittle releases, hidden performance costs, and binary-compatibility breaks. You need a simple, reliable decision framework and copy-paste-ready code that works in modern .NET 8 Minimal APIs with DI.

Prerequisites

  • .NET 8 SDK: Needed to compile and run the Minimal API and C# 12 features.
  • An editor (Visual Studio Code or Visual Studio 2022+): For building and debugging the examples.
  • Azure CLI (optional): If you apply the security section with Managed Identity and RBAC for external configuration.

The Solution (Step-by-Step)

1) What const means

  • const is a compile-time constant. The value is inlined at call sites during compilation.
  • Only allowed for types with compile-time constants (primitive numeric types, char, bool, string, and enum).
  • Changing a public const value in a library can break consumers until they recompile, because callers hold the old inlined value.
// File: AppConstants.cs
namespace MyApp;

// Static class is acceptable here because it only holds constants and does not manage state or dependencies.
public static class AppConstants
{
    // Compile-time literals. Safe to inline and extremely fast to read.
    public const string AppName = "OrdersService";    // Inlined at compile-time
    public const int DefaultPageSize = 50;             // Only use if truly invariant
}

2) What readonly means

  • readonly fields are assigned exactly once at runtime: either at the declaration or in a constructor.
  • Use readonly when the value is not known at compile-time (e.g., injected through DI, environment-based, or computed) but must not change after creation.
  • static readonly is runtime-initialized once per type and is not inlined by callers, preserving binary compatibility across versions.
// File: Slug.cs
namespace MyApp;

// Simple immutable value object using readonly field.
public sealed class Slug
{
    public readonly string Value; // Assigned once; immutable thereafter.

    public Slug(string value)
    {
        // Validate then assign. Once assigned, cannot change.
        Value = string.IsNullOrWhiteSpace(value)
            ? throw new ArgumentException("Slug cannot be empty")
            : value.Trim().ToLowerInvariant();
    }
}

3) Prefer static readonly for non-literal shared values

  • Use static readonly for objects like Regex, TimeSpan, Uri, or configuration-derived values that are constant for the process lifetime.
// File: Parsing.cs
using System.Text.RegularExpressions;

namespace MyApp;

public static class Parsing
{
    // Compiled Regex cached for reuse. Not a compile-time literal, so static readonly, not const.
    public static readonly Regex SlugPattern = new(
        pattern: "^[a-z0-9-]+$",
        options: RegexOptions.Compiled | RegexOptions.CultureInvariant
    );
}

4) Minimal API (.NET 8) with DI using readonly

// File: Program.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;

namespace MyApp;

// Options record for settings that may vary by environment.
public sealed record PaginationOptions(int DefaultPageSize, int MaxPageSize);

// Service depending on options. Primary constructor for clarity.
public sealed class ProductService(IOptions<PaginationOptions> options)
{
    private readonly int _defaultPageSize = options.Value.DefaultPageSize; // readonly: set once from DI

    public IResult List(int? pageSize)
    {
        // Enforce immutable default from DI; callers can't mutate _defaultPageSize
        var size = pageSize is > 0 ? pageSize.Value : _defaultPageSize;
        return Results.Ok(new { PageSize = size, Source = "DI/readonly" });
    }
}

var builder = WebApplication.CreateBuilder(args);

// Bind options from configuration once. Keep them immutable after construction.
builder.Services.Configure<PaginationOptions>(builder.Configuration.GetSection("Pagination"));

// Register ProductService for DI.
builder.Services.AddSingleton<ProductService>();

var app = builder.Build();

// Use const for literal routes and tags: truly invariant strings.
app.MapGet("/products", (ProductService svc, int? pageSize) => svc.List(pageSize))
   .WithTags(AppConstants.AppName);

app.Run();

5) When to use which (decision rules)

  • Use const when: the value is a true literal that will never change across versions, and you accept inlining (e.g., mathematical constants, semantic tags, fixed route segments).
  • Use readonly when: the value is computed, injected, environment-specific, or may change across versions without forcing consumer recompilation.
  • Use static readonly for: reference types (Regex, TimeSpan, Uri) or structs not representable as compile-time constants, shared across the app.
  • Avoid public const in libraries for values that might change; prefer public static readonly to avoid binary-compat issues.

6) Performance and threading

  • const reads are effectively free due to inlining.
  • static readonly reads are a single memory read; their initialization is thread-safe under the CLR type initializer semantics.
  • RegexOptions.Compiled with static readonly avoids repeated parsing and allocation under load.

7) Advanced: readonly struct for immutable value types

  • Use readonly struct to guarantee all instance members do not mutate state and to enable defensive copies avoidance by the compiler.
  • Prefer struct only for small, immutable value types to avoid copying overhead.
// File: Money.cs
namespace MyApp;

public readonly struct Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }

    // Methods cannot mutate fields because the struct is readonly.
    public Money Convert(decimal rate) => new(Amount * rate, Currency);
}

8) Binary compatibility and versioning

  • Public const values are inlined into consuming assemblies. If you change the const and do not recompile consumers, they keep the old value. This is a breaking behavior.
  • Public static readonly values are not inlined. Changing them in your library updates behavior without requiring consumer recompilation.
  • Guideline: For public libraries, avoid public const except for values guaranteed to never change (e.g., mathematical constants or protocol IDs defined as forever-stable).

9) Testing and static analysis

  • Roslyn analyzers: Enable CA1802 (use const) to suggest const when fields can be made const; enable IDE0044 to suggest readonly for fields assigned only in constructor.
  • CI/CD: Treat analyzer warnings as errors for categories Design, Performance, and Style to enforce immutability usage consistently.
  • Unit tests: Assert immutability by verifying no public setters exist and by attempting to mutate through reflection only in dedicated tests if necessary.

10) Cross-language note: TypeScript immutability parallel

If your stack includes TypeScript, mirror the C# intent with readonly and schema validation.

// File: settings.ts
// Strict typing; no 'any'. Enforce immutability on config and validate with Zod.
import { z } from "zod";

// Zod schema for runtime validation
export const ConfigSchema = z.object({
  apiBaseUrl: z.string().url(),
  defaultPageSize: z.number().int().positive(),
}).strict();

export type Config = Readonly<{
  apiBaseUrl: string;           // readonly by type
  defaultPageSize: number;      // readonly by type
}>;

export function loadConfig(env: NodeJS.ProcessEnv): Config {
  // Validate at runtime, then freeze object to mimic readonly semantics
  const parsed = ConfigSchema.parse({
    apiBaseUrl: env.API_BASE_URL,
    defaultPageSize: Number(env.DEFAULT_PAGE_SIZE ?? 50),
  });
  return Object.freeze(parsed) as Config;
}

Best Practices & Security

  • Best Practice: Use const only for literals that are guaranteed stable across versions. For anything configuration-related, prefer readonly or static readonly loaded via DI.
  • Best Practice: Static classes holding only const or static readonly are acceptable because they do not manage state or dependencies.
  • Security: If loading values from Azure services (e.g., Azure App Configuration or Key Vault), use Managed Identity instead of connection strings. Grant the minimal RBAC roles required: for Azure App Configuration, assign App Configuration Data Reader to the managed identity; for Key Vault, assign Key Vault Secrets User; for reading resource metadata, the Reader role is sufficient. Do not embed secrets in const or readonly fields.
  • Operational Safety: Avoid public const for values that may change; use public static readonly to prevent consumer inlining issues and to reduce breaking changes.
  • Observability: Expose configuration values carefully in logs; never log secrets. If you must log, redact or hash values and keep them in readonly fields populated via DI.

Summary

  • Use const for true compile-time literals that never change; prefer static readonly for public values to avoid consumer recompilation.
  • Use readonly (and static readonly) for runtime-initialized, immutable values, especially when sourced via DI or environment configuration.
  • Harden production: enforce analyzers in CI, adopt Managed Identity with least-privilege RBAC, and avoid embedding secrets or changeable values in const.