Transactions - lobodava/artisan-orm GitHub Wiki

Artisan.Orm offers two transaction patterns — manual commit and auto commit / rollback — plus async variants for both. Pick based on which outcome should be the default when the action returns:

Method Default outcome Caller's responsibility
BeginTransaction(...) rollback call tran.Commit() to persist
RunInTransaction(...) commit on success nothing — handled automatically
BeginTransactionAsync(...) rollback await tran.CommitAsync(ct)
RunInTransactionAsync(...) commit on success nothing — handled automatically

All four methods accept an optional IsolationLevel; when omitted, SQL Server uses its default (normally READ COMMITTED).

Inside action, any commands issued through GetByCommand / ExecuteCommand / Read* / Execute* on the same repository automatically attach to the started SqlTransaction.

BeginTransaction — manual commit

Use when "do nothing" should mean "rolled back" — typical for read-then-modify flows that may decide partway through to abandon the change.

public void CheckRuleForUser(int userId)
{
    BeginTransaction(IsolationLevel.RepeatableRead, tran =>
    {
        var user = GetByCommand(cmd =>
        {
            cmd.UseProcedure("dbo.GetUserById");
            cmd.AddIntParam("@Id", userId);
            return cmd.ReadTo<User>();
        });

        if (Array.IndexOf(user.RoleIds, 2) > -1 && Array.IndexOf(user.RoleIds, 3) == -1)
        {
            var newRoleIds = user.RoleIds.ToList();
            newRoleIds.Add(3);
            user.RoleIds = newRoleIds.ToArray();
        }

        ExecuteCommand(cmd =>
        {
            cmd.UseProcedure("dbo.SaveUser");
            cmd.AddTableRowParam("@User", user);
            cmd.AddTableParam("@RoleIds", user.RoleIds);
        });

        // Forgetting tran.Commit() here would roll back — by design.
        tran.Commit();
    });
}

The method opens the connection if it was closed, begins the transaction, runs action, and on exit:

  • try — runs action,
  • catch — rolls back the transaction and rethrows,
  • finally — disposes the transaction (which rolls back if Commit() was not called) and closes the connection if it was opened by this method.

RunInTransaction — auto commit on success

Use for the typical "do these steps; if any throws, undo all" flow.

public void TransferFunds(int fromAccountId, int toAccountId, decimal amount)
{
    RunInTransaction(IsolationLevel.Serializable, tran =>
    {
        Execute("dbo.MoveFunds",
            new SqlParameter("@From",   fromAccountId),
            new SqlParameter("@To",     toAccountId),
            new SqlParameter("@Amount", amount));

        Execute("dbo.RecordTransfer",
            new SqlParameter("@From",   fromAccountId),
            new SqlParameter("@To",     toAccountId),
            new SqlParameter("@Amount", amount));

        // No tran.Commit() here — RunInTransaction commits automatically
        // on normal return, and rolls back if action throws.
    });
}

If action throws, the transaction is rolled back and the exception is rethrown. The caller must not call Commit or Rollback manually inside action.

Async variants

Both BeginTransactionAsync and RunInTransactionAsync accept a CancellationToken and pass it through to every awaited call inside.

public async Task TransferFundsAsync(int fromId, int toId, decimal amount,
                                     CancellationToken ct = default)
{
    await RunInTransactionAsync(IsolationLevel.Serializable, async (tran, ct2) =>
    {
        await ExecuteAsync("dbo.MoveFunds", ct2,
            new SqlParameter("@From",   fromId),
            new SqlParameter("@To",     toId),
            new SqlParameter("@Amount", amount));

        await ExecuteAsync("dbo.RecordTransfer", ct2,
            new SqlParameter("@From",   fromId),
            new SqlParameter("@To",     toId),
            new SqlParameter("@Amount", amount));
    }, ct);
}

For the manual-commit async version:

await BeginTransactionAsync(async (tran, ct) =>
{
    // ... reads / writes ...
    await tran.CommitAsync(ct).ConfigureAwait(false);
}, cancellationToken);

Stored procedures and outer transactions

To allow an outer transaction (started in C#) to coexist with a procedure that may also want its own when called standalone, write each procedure to detect and respect an existing one:

create procedure dbo.SaveUser
    ...
as
begin
    declare @StartTranCount int;
    begin try
        set @StartTranCount = @@trancount;

        -- Begin transaction only when there is no outer transaction
        if @StartTranCount = 0 begin transaction;

        begin -- save Users
            ...
        end;

        begin -- save UserRoles
            ...
        end;

        -- Commit only when this proc started the transaction
        if @StartTranCount = 0 commit transaction;
    end try
    begin catch
        if xact_state() <> 0 and @StartTranCount = 0 rollback transaction;

        declare @ErrorMessage nvarchar(4000) = dbo.GetErrorMessage();
        raiserror (@ErrorMessage, 16, 1);
        return;
    end catch;
end;

See also:

⚠️ **GitHub.com Fallback** ⚠️