Dapper
Async Programming
Transactions
C# Development
.NET

Dapper async and transaction

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Using Dapper with async and transactions is straightforward once connection lifetime and transaction scope are explicit. The core rule is simple: open one connection, begin one transaction, pass that transaction to every command, and commit only after all awaited operations succeed.

Many low-level Q and A style snippets solve the immediate error but skip the engineering context that keeps code reliable over time. A durable solution combines correct syntax with predictable behavior under real inputs, explicit failure handling, and verification that future refactors do not regress the outcome.

When evaluating a fix, also consider maintenance reality: who will own this code in six months, what observability exists in production, and which assumptions are most likely to break first. Capturing intent with small regression tests and clear naming drastically reduces re-learning cost when incidents happen under time pressure.

Core Sections

1. Start with the smallest correct implementation

A minimal unit-of-work pattern keeps behavior deterministic. Keep the transaction close to the calling method so failures are visible and rollback decisions are obvious.

csharp
1await using var conn = new SqlConnection(connectionString);
2await conn.OpenAsync();
3await using var tx = await conn.BeginTransactionAsync();
4
5try
6{
7    await conn.ExecuteAsync(
8        "INSERT INTO Orders(CustomerId) VALUES (@CustomerId)",
9        new { CustomerId = 42 },
10        transaction: tx);
11
12    await conn.ExecuteAsync(
13        "UPDATE Inventory SET Qty = Qty - 1 WHERE Sku = @Sku",
14        new { Sku = "ABC-1" },
15        transaction: tx);
16
17    await tx.CommitAsync();
18}
19catch
20{
21    await tx.RollbackAsync();
22    throw;
23}

This baseline should be intentionally simple. Keep naming precise, make assumptions visible, and avoid premature abstractions. Once the smallest version behaves correctly, you gain a trustworthy reference point for future optimization and architectural changes.

At this stage, add lightweight assertions or logging around critical state transitions. That evidence is invaluable when later optimizations accidentally change behavior, because you can quickly compare current output against the known-good baseline rather than guessing where divergence started.

2. Harden the implementation for real usage

If you split logic into repository methods, pass both IDbConnection and IDbTransaction down the stack. Avoid hidden connection creation in lower layers or your work will escape the intended transaction boundary.

csharp
1public Task<int> InsertPaymentAsync(
2    IDbConnection conn, IDbTransaction tx, Payment payment)
3{
4    const string sql = "INSERT INTO Payments(Amount, Ref) VALUES (@Amount, @Ref)";
5    return conn.ExecuteAsync(sql, payment, tx);
6}
7
8public async Task SaveAsync(Payment payment)
9{
10    await using var conn = new SqlConnection(_cs);
11    await conn.OpenAsync();
12    await using var tx = await conn.BeginTransactionAsync();
13    try
14    {
15        await InsertPaymentAsync(conn, tx, payment);
16        await tx.CommitAsync();
17    }
18    catch
19    {
20        await tx.RollbackAsync();
21        throw;
22    }
23}

Production hardening is where many bugs are prevented. Address resource management, thread or event-loop safety, edge cases, and consistent error paths. If this logic is part of a service boundary, include clear contracts for inputs, outputs, and failure semantics.

It also helps to separate pure transformation logic from side-effectful operations such as network calls, database writes, or UI mutation. That split makes unit tests faster and deterministic, while integration tests can focus on boundary behavior and failure recovery policies.

3. Verify behavior and performance

Add cancellation tokens and command timeouts for resilience. Also classify exceptions so transient network failures can be retried safely when operations are idempotent. Integration tests should assert both commit and rollback behavior against a real test database, not just mocks.

A practical verification loop is straightforward and effective: one happy-path test, one edge-case test, and one failure-path test. Then run with representative data volume or user interactions. If behavior changes after refactoring, keep the regression test so the same issue does not return later.

Performance validation should align with user impact. For APIs, inspect latency percentiles and error rate. For mobile features, monitor frame drops and main-thread stalls. For algorithms and libraries, track complexity growth and memory churn under scaled inputs. Metrics tied to real outcomes keep optimization decisions grounded.

Common Pitfalls

  • Opening a new connection inside repository methods and breaking transaction scope.
  • Forgetting to pass transaction into every Dapper call.
  • Starting parallel commands on the same connection without provider support.
  • Swallowing exceptions and accidentally committing partial writes.
  • Testing only success paths and never verifying rollback correctness.

Summary

Dapper async with transactions is reliable when scope is explicit and consistently propagated. Keep connection and transaction ownership clear, then test failure paths as seriously as success paths. Pair concise implementation with explicit validation, and you get code that is both understandable today and maintainable as requirements evolve.


Course illustration
Course illustration

All Rights Reserved.