Monday, 5 January 2026

Unit of Work with Dapper + SQL Server: Stop partial writes and make transactions boring

 When you build APIs with Dapper, you get performance and control — but you also take responsibility for things that full ORMs often hide, especially transaction management. The result in many codebases is the same: multi-step operations that sometimes fail halfway through, leaving partial data in the database.

The Unit of Work (UoW) pattern is a simple, practical fix. It gives your application an explicit, repeatable way to say:

These changes belong together. Commit them as one, or roll them all back.

This article explains what Unit of Work is, why it’s valuable with Dapper, common anti-patterns, and a complete example you can adapt.

What is Unit of Work?

Unit of Work is a design pattern that:

  • Defines a transaction boundary around a business operation
  • Ensures multiple database writes across repositories are atomic
  • Centralizes Begin/Commit/Rollback so you don’t duplicate transaction code everywhere

With Dapper, “Unit of Work” usually means: a single shared SqlConnection + SqlTransaction that multiple repositories use for the duration of one use-case.

Why it matters (especially with Dapper)

Imagine a “checkout” workflow:

  • Insert order
  • Reserve inventory
  • Insert audit record

If the inventory reservation fails, do you want an “order” row sitting in the database with no reservation? Usually: no.

Unit of Work gives you:

  • Atomicity: all writes succeed or none do
  • Consistency: repositories share the same connection + transaction
  • Cleaner code: transaction handling is in one place
  • Better testability: you can test business behavior without HTTP plumbing

The common anti-pattern (what goes wrong)

A frequent Dapper mistake looks like this:

  • Each repository method opens its own connection
  • Each method executes SQL independently
  • No transaction spans the whole operation

What can happen:

  • Step 1 succeeds and writes to DB
  • Step 2 fails (constraint, timeout, network hiccup)
  • The API returns an error, but the database now contains half the story

Even worse: teams try to patch it by sprinkling BeginTransaction() in random repositories — leading to “transaction soup”, nested transaction confusion, and hard-to-debug behavior.

Principle: repositories should execute SQL; the service/use-case should own the business transaction boundary.

A clean Unit of Work implementation for SQL Server

Below is a complete, publishable example using Dapper + SQL Server with Microsoft.Data.SqlClient.

1) The Unit of Work contract

using System.Data;

public interface IUnitOfWork : IDisposable
{
IDbConnection Connection { get; }
IDbTransaction Transaction { get; }

void Begin();
void Commit();
void Rollback();
}

2) SQL Server Unit of Work implementation

using System.Data;
using Microsoft.Data.SqlClient;

public sealed class SqlUnitOfWork : IUnitOfWork
{
private readonly string _connectionString;
private IDbConnection? _connection;
private IDbTransaction? _transaction;

public SqlUnitOfWork(string connectionString)
{
_connectionString = connectionString;
}

public IDbConnection Connection =>
_connection ?? throw new InvalidOperationException("UnitOfWork has not begun. Call Begin().");

public IDbTransaction Transaction =>
_transaction ?? throw new InvalidOperationException("Transaction has not begun. Call Begin().");

public void Begin()
{
if (_transaction != null)
throw new InvalidOperationException("Transaction already started.");

_connection = new SqlConnection(_connectionString);
_connection.Open();
_transaction = _connection.BeginTransaction();
}

public void Commit()
{
if (_transaction == null)
throw new InvalidOperationException("No transaction to commit.");

_transaction.Commit();
Dispose();
}

public void Rollback()
{
if (_transaction == null)
return;

_transaction.Rollback();
Dispose();
}

public void Dispose()
{
_transaction?.Dispose();
_transaction = null;

_connection?.Dispose();
_connection = null;
}
}

Repositories that participate in the Unit of Work

Key rule: every Dapper call that must be in the transaction passes the shared Transaction.

Orders repository

using Dapper;

public sealed class OrdersRepository
{
private readonly IUnitOfWork _uow;

public OrdersRepository(IUnitOfWork uow) => _uow = uow;

public Task<long> CreateAsync(long customerId, decimal total)
{
const string sql = @"
insert into Orders(CustomerId, Total, Status)
values (@CustomerId, @Total, 'Pending');
select cast(scope_identity() as bigint);"
;

return _uow.Connection.ExecuteScalarAsync<long>(
sql,
new { CustomerId = customerId, Total = total },
_uow.Transaction
);
}
}

Inventory repository (with a safety check)

using Dapper;

public sealed class InventoryRepository
{
private readonly IUnitOfWork _uow;

public InventoryRepository(IUnitOfWork uow) => _uow = uow;

public async Task ReserveAsync(long productId, int qty)
{
const string sql = @"
update Inventory
set Reserved = Reserved + @Qty
where ProductId = @ProductId
and (InStock - Reserved) >= @Qty;"
;

var rows = await _uow.Connection.ExecuteAsync(
sql,
new { ProductId = productId, Qty = qty },
_uow.Transaction
);

if (rows == 0)
throw new InvalidOperationException("Not enough inventory to reserve.");
}
}

Audit repository

using Dapper;

public sealed class AuditRepository
{
private readonly IUnitOfWork _uow;

public AuditRepository(IUnitOfWork uow) => _uow = uow;

public Task WriteAsync(string eventName, string payloadJson)
{
const string sql = @"
insert into AuditLog(EventName, PayloadJson, CreatedUtc)
values (@EventName, @PayloadJson, sysutcdatetime());"
;

return _uow.Connection.ExecuteAsync(
sql,
new { EventName = eventName, PayloadJson = payloadJson },
_uow.Transaction
);
}
}

The use-case/service layer: the transaction boundary lives here

This is where Unit of Work shines. Your service does multiple steps, and commits once at the end.

public sealed class CheckoutService
{
private readonly IUnitOfWork _uow;
private readonly OrdersRepository _orders;
private readonly InventoryRepository _inventory;
private readonly AuditRepository _audit;

public CheckoutService(
IUnitOfWork uow,
OrdersRepository orders,
InventoryRepository inventory,
AuditRepository audit
)

{
_uow = uow;
_orders = orders;
_inventory = inventory;
_audit = audit;
}

public async Task<long> PlaceOrderAsync(long customerId, long productId, int qty, decimal total)
{
_uow.Begin();

try
{
var orderId = await _orders.CreateAsync(customerId, total);
await _inventory.ReserveAsync(productId, qty);
await _audit.WriteAsync("OrderPlaced", $$"""{"orderId":{{orderId}}}""");

_uow.Commit();
return orderId;
}
catch
{
_uow.Rollback();
throw;
}
}
}

Outcome: if inventory reservation fails, the order insert is rolled back. No partial saves.

Keeping controllers thin (example endpoint)

Controllers should orchestrate HTTP concerns, not transactions.

using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/checkout")]
public sealed class CheckoutController : ControllerBase
{
private readonly CheckoutService _checkout;

public CheckoutController(CheckoutService checkout) => _checkout = checkout;

[HttpPost("place-order")]
public async Task<ActionResult<long>> PlaceOrder(PlaceOrderDto dto)
{
var orderId = await _checkout.PlaceOrderAsync(dto.CustomerId, dto.ProductId, dto.Qty, dto.Total);
return Ok(orderId);
}
}

public sealed record PlaceOrderDto(long CustomerId, long ProductId, int Qty, decimal Total);

Dependency Injection: correct lifetime and registration

With ASP.NET Core’s built-in container, register UoW as Scoped (one per request):

// Program.cs (or Startup.cs ConfigureServices)

var cs = builder.Configuration.GetConnectionString("AppDb");

builder.Services.AddScoped<IUnitOfWork>(_ => new SqlUnitOfWork(cs));

builder.Services.AddScoped<OrdersRepository>();
builder.Services.AddScoped<InventoryRepository>();
builder.Services.AddScoped<AuditRepository>();
builder.Services.AddScoped<CheckoutService>();

Why Scoped? It ensures every repository used during a request can share the same UoW instance (and therefore the same transaction) when the service begins it.

When NOT to use Unit of Work

Unit of Work is powerful, but don’t force it everywhere.

  • Read-only endpoints: if an endpoint is just SELECT, you don’t need a transaction boundary. (SQL Server can still use snapshot semantics depending on configuration, but UoW doesn’t add value here.)
  • Single-statement writes: if the entire operation is a single UPDATE or INSERT and nothing else depends on it, UoW may be overkill.
  • When you already have a natural transactional wrapper: for example, if you use stored procedures that encapsulate all writes internally (and handle their own transactions), UoW at the app layer might duplicate work.

That said, the moment you have “write to A, then write to B”, UoW becomes valuable again.

Practical tips (SQL Server + Dapper)

  • Use Microsoft.Data.SqlClient for SQL Server connections.
  • Always pass the transaction to Dapper calls that must be atomic.
  • Avoid starting transactions in repositories. Keep transaction ownership in the service/use-case layer.
  • Prefer short transactions. Do not call external services (HTTP calls, message brokers) inside an open SQL transaction unless you really understand the latency/locking consequences.
  • Consider adding isolation level explicitly if your domain needs it (BeginTransaction(IsolationLevel.ReadCommitted) etc.).

Summary

Using Dapper doesn’t mean giving up clean architecture. The Unit of Work pattern is one of the simplest ways to:

  • prevent partial saves,
  • keep your business operations consistent,
  • and make transaction handling predictable across the codebase.

Hope you like the article. Happy Programming.

Friday, 2 January 2026

Securing Enterprise REST APIs in .NET with Azure AD: Principles, Pitfalls, and Production-Ready Patterns

 Enterprise APIs don’t become “secure” because they use HTTPS and JWTs. Real-world security is a set of principles, implemented as repeatable defaults, backed by operational controls (monitoring, rotation, scanning), and hardened against misconfiguration.

This article walks through the security principles you should follow for an enterprise REST API built with ASP.NET Core (.NET 8) and Azure AD (Microsoft Entra ID) — with clean code examples you can adapt directly.

1) Security principles (the enterprise baseline)

These principles shape every design choice:

  • Default deny: Every endpoint requires authentication unless explicitly opened.
  • Least privilege: Users/services only get the permissions they need (scopes/app roles).
  • Defense in depth: Multiple layers (gateway + app + data layer + monitoring).
  • Fail securely: Errors should not leak implementation details; auth failures should be consistent.
  • Reduce attack surface: Disable unnecessary endpoints/features; lock down CORS/metrics.
  • Protect secrets: No secrets in source control; store in Key Vault; rotate.
  • Observe & audit: Structured logs, correlation IDs, audit trails.
  • Validate input, encode output: Prevent injection, deserialization abuse, and data leakage.
  • Secure dependencies: Patch cadence + scanning + SBOM.
  • Operational readiness: Rate limiting, DDoS protection, incident response, and repeatable config.

2) Azure AD authentication done right (JWT bearer)

Use Microsoft.Identity.Web so you inherit best practices and avoid hand-rolling JWT validation.

Install packages

dotnet add package Microsoft.Identity.Web
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

appsettings.json (no secrets here)

{
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "<tenant-guid>",
"ClientId": "<api-app-registration-client-id>"
}
}

> For APIs, you typically do not need ClientSecret unless you call downstream APIs on behalf of the app/user.

Program.cs — authentication + strict defaults

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web;
using Microsoft.IdentityModel.Logging;

var builder = WebApplication.CreateBuilder(args);

// Never log identity PII outside dev
IdentityModelEventSource.ShowPII = builder.Environment.IsDevelopment();

builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));

builder.Services.AddAuthorization();

builder.Services.AddControllers();

var app = builder.Build();

app.UseHttpsRedirection();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

app.Run();

3) Authorization: use scopes/app roles + “default deny”

Authentication answers: “Who are you?”

Authorization answers: “What are you allowed to do?”

Enterprise APIs should not rely on route string checks or ad-hoc middleware exceptions. Instead:

  • Require auth for all endpoints by default
  • Use scopes/app roles for fine-grained access
  • Only explicitly allow anonymous for health/public endpoints

Global “require authenticated user” (Fallback policy)

builder.Services.AddAuthorization(options =>
{
// Default deny: anything not explicitly allowed requires authentication
options.FallbackPolicy = options.DefaultPolicy;
});

Now any controller/action without [AllowAnonymous] requires auth.

Scope-based authorization (recommended)

If your API uses scopes like api://<client-id>/read:

builder.Services.AddAuthorization(options =>
{
options.AddPolicy("RequireReadScope", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scp", "read"); // "scp" claim for delegated permissions
});
});

Use it in controllers:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/reports")]
public class ReportsController : ControllerBase
{
[HttpGet]
[Authorize(Policy = "RequireReadScope")]
public IActionResult GetReports() => Ok(new { Status = "ok" });
}

App role–based authorization (service-to-service friendly)

For application permissions you typically use roles claim:

builder.Services.AddAuthorization(options =>
{
options.AddPolicy("RequireAdminRole", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("roles", "Admin");
});
});

4) CORS: don’t use AllowAnyOrigin() in production

CORS is not “security” by itself (it’s a browser enforcement), but misconfigured CORS is a common enterprise audit finding and can amplify token misuse.

builder.Services.AddCors(options =>
{
options.AddPolicy("FrontendOnly", policy =>
{
policy
.WithOrigins("https://app.company.com")
.AllowAnyHeader()
.AllowAnyMethod()
.WithExposedHeaders("X-Request-ID");
});
});

app.UseCors("FrontendOnly");

Rules of thumb

  • Keep origins explicit
  • Avoid WithExposedHeaders(“*”)
  • Don’t allow credentials unless you truly need them

5) Secure error handling (don’t leak internals)

A production API should return safe messages, while logs retain details.

app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = 500;
context.Response.ContentType = "application/json";

var safeMessage = app.Environment.IsDevelopment()
? "Internal error (dev mode). Check logs for details."
: "Internal error occurred. Please contact support.";

await context.Response.WriteAsync($@"{{""error"":""{safeMessage}""}}");
});
});

Avoid

  • Returning exception messages to clients in production
  • Returning stack traces
  • Logging tokens/claims/PII

6) Secrets management: Key Vault + environment variables (never git)

Enterprise rule: If it’s a secret, it must be rotatable and must not be in source control.

Recommended:

  • Azure Key Vault (prod)
  • Environment variables / managed identity
  • Secret scanning in CI
  • Rotation procedures

If you must access Key Vault:

// Requires Azure.Identity + Key Vault configuration (example conceptually)
builder.Configuration.AddEnvironmentVariables();
// In Azure: configure Key Vault references or add AzureKeyVault provider

7) Protect operational endpoints (/metrics, admin routes)

Prometheus metrics and management endpoints are often unintentionally public.

Two good patterns:

  • Put them behind network controls (private endpoint / internal ingress)
  • Or require authentication / a shared secret (as a fallback)

Example: require auth for /metrics

app.MapGet("/metrics", () => Results.Text("..."))
.RequireAuthorization(); // simplest: same auth pipeline as API

If you must use a secret header (less ideal than auth):

app.MapGet("/metrics", (HttpContext ctx, IConfiguration cfg) =>
{
var expected = cfg["Ops:MetricsKey"];
var incoming = ctx.Request.Headers["X-METRICS-KEY"].ToString();
if (incoming != expected) return Results.Unauthorized();
return Results.Text("...");
});

8) Rate limiting (protect availability)

Availability is a security property. Add rate limiting at gateway + app.

using System.Threading.RateLimiting;

builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("api", limiterOptions =>
{
limiterOptions.Window = TimeSpan.FromSeconds(10);
limiterOptions.PermitLimit = 100;
limiterOptions.QueueLimit = 0;
});
});

app.UseRateLimiter();

app.MapControllers().RequireRateLimiting("api");

9) Input validation and injection prevention

Enterprise APIs routinely fail here — not at auth.

  • Validate request models (length, ranges, allowed values)
  • Avoid dynamic SQL and string concatenation in queries
  • Parameterize everything (Dapper/EF Core)
  • Avoid unsafe deserialization settings

Example: model validation

public sealed class CreateUserRequest
{
[Required, EmailAddress]
public string Email { get; set; } = default!;

[Range(1, 100)]
public int DepartmentId { get; set; }
}

In controllers, rely on [ApiController] to automatically return 400 for invalid models.

10) Secure logging: correlation IDs, structured logs, no tokens

  • Add a request ID (X-Request-ID) and include it in all logs
  • Log who did what, but never log:

=> Authorization headers
=> raw JWTs
=> secrets
=> PII unless necessary and approved

Example: simple correlation header

app.Use(async (ctx, next) =>
{
var requestId = ctx.Request.Headers.TryGetValue("X-Request-ID", out var incoming)
? incoming.ToString()
: ctx.TraceIdentifier;

ctx.Response.Headers["X-Request-ID"] = requestId;
await next();
});

11) Security headers & HTTPS

  • Enforce HTTPS redirection
  • Enable HSTS in production
    HSTS (HTTP Strict Transport Security) is a security policy that forces web browsers to use only secure HTTPS connections for a website, preventing attackers from downgrading connections to unsecure HTTP (SSL stripping) and protecting against man-in-the-middle attacks.
  • Consider adding headers (depending on hosting/gateway)
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseHttpsRedirection();

12) Dependency and supply-chain hygiene

Enterprise minimum:

  • Dependabot/Renovate
  • dotnet list package — vulnerable
  • SBOM generation
  • Locked down build pipeline permissions

Example command:

dotnet list package --vulnerable

13) Production checklist

Use this as a release gate:

  1. Auth
  • Default deny enabled (fallback policy)
  • Tokens validated by Microsoft Identity Web
  • ShowPII disabled outside dev

2. AuthZ

  • Scopes/app roles enforced on sensitive endpoints
  • No route-string-based bypass logic

3. CORS

  • Explicit allowed origins in prod

4. Errors

  • No stack traces or exception messages returned in prod

5. Secrets

  • No secrets in repo; Key Vault + rotation done
  • Secret scanning enabled

6. Ops endpoints

  • /metrics not public
  • Admin endpoints locked down

7. Abuse protection

  • Rate limiting enabled
  • Gateway/WAF in front

8. Data layer

  • Parameterized queries only
  • Input validation on all externally controlled fields

9. Observability

10. Dependencies

  • Vulnerability scanning + patching cadence

Closing thoughts

A secure enterprise API is mostly about secure defaults and removing foot-guns:

  • stop relying on “special-case routes”
  • protect internal endpoints
  • never ship with permissive CORS
  • never commit secrets
  • enforce authorization consistently and centrally

Hope you like the articles. Happy secured REST APIs programming.