Skip to content

Convention-based stored procedure toolkit for .NET — built on Dapper

License

Notifications You must be signed in to change notification settings

mcandiri/DapperForge

Repository files navigation

DapperForge

Convention-based stored procedure toolkit for .NET

Your team's SP naming convention shouldn't require boilerplate.

.NET 8 License: MIT Build & Test


The Problem

Every team that uses stored procedures writes the same code over and over:

// This. Every. Single. Time.
using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
var parameters = new DynamicParameters();
parameters.Add("@IsActive", true);
var students = await connection.QueryAsync<Student>(
    "Get_Students",
    parameters,
    commandType: CommandType.StoredProcedure
);
return students;

The Solution

var students = await forge.GetAsync<Student>(new { IsActive = true });

One line. Same result. Convention handles the rest.


Quick Start

Clone and reference the project:

git clone https://github.com/mcandiri/DapperForge.git
dotnet add reference path/to/DapperForge/src/DapperForge/DapperForge.csproj
// Program.cs
builder.Services.AddDapperForge(options =>
{
    options.ConnectionString = "Server=...;Database=...;";
    options.Provider = DatabaseProvider.SqlServer;
});
// StudentService.cs
public class StudentService(IForgeConnection forge)
{
    public Task<IEnumerable<Student>> GetAll()
        => forge.GetAsync<Student>();

    public Task<Student?> GetById(int id)
        => forge.GetSingleAsync<Student>(new { Id = id });

    public Task<int> Save(Student student)
        => forge.SaveAsync(student);

    public Task<int> Remove(int id)
        => forge.RemoveAsync<Student>(new { Id = id });
}

That's it. GetAsync resolves to Get_Students, SaveAsync to Save_Students, RemoveAsync to Remove_Students.


Convention Engine

The core of DapperForge. Define your naming pattern once:

builder.Services.AddDapperForge(options =>
{
    options.ConnectionString = "...";

    options.SetConvention(c =>
    {
        c.SelectPrefix = "sel";       // sel_Students
        c.UpsertPrefix = "up";        // up_Students
        c.DeletePrefix = "del";       // del_Students
        c.Schema = "dbo";             // dbo.sel_Students
        c.Separator = "_";
    });

    // Override for specific entities
    options.MapEntity<Student>("Ogrenciler");  // dbo.sel_Ogrenciler

    // Or change the global resolver
    options.EntityNameResolver = type => type.Name.ToLowerInvariant();
});
Call Default Convention Custom (sel/up/del + dbo)
GetAsync<Student>() Get_Students dbo.sel_Students
SaveAsync(student) Save_Students dbo.up_Students
RemoveAsync<Student>() Remove_Students dbo.del_Students

Direct SP Calls

When you need to call an SP that doesn't follow your convention:

// Query
var honors = await forge.ExecuteSpAsync<Student>("rpt_HonorRoll", new { Year = 2024 });

// Scalar
var count = await forge.ExecuteSpScalarAsync<int>("sel_StudentCount");

// Non-query
await forge.ExecuteSpNonQueryAsync("job_CleanupExpired", new { DaysOld = 30 });

Multiple Result Sets

// Two result sets
var (students, teachers) = await forge.ExecuteSpMultiAsync<Student, Teacher>(
    "sel_ClassroomDetails", new { ClassId = 5 });

// Three result sets
var (orders, items, summary) = await forge.ExecuteSpMultiAsync<Order, OrderItem, Summary>(
    "sel_OrderReport", new { Year = 2024 });

Output Parameters

var result = await forge.ExecuteSpWithOutputAsync(
    "up_Students",
    new { Name = "John", Email = "john@test.com" },
    new Dictionary<string, DbType> { ["NewId"] = DbType.Int32 });

var newId = result.OutputValues["NewId"];  // 42

Transactions

// Automatic — commits on success, rolls back on exception
await forge.InTransactionAsync(async tx =>
{
    await tx.SaveAsync(order);
    await tx.SaveAsync(orderLine);
    await tx.RemoveAsync<CartItem>(new { CartId = cartId });
});
// Manual — full control
using var tx = forge.BeginTransaction();
try
{
    await tx.SaveAsync(order);
    await tx.SaveAsync(orderLine);
    tx.Commit();
}
catch
{
    tx.Rollback();
    throw;
}

Diagnostics

builder.Services.AddDapperForge(options =>
{
    options.EnableDiagnostics = true;
    options.SlowQueryThreshold = TimeSpan.FromSeconds(2);

    // Hook into every execution
    options.OnQueryExecuted = e =>
    {
        // e.SpName, e.Duration, e.RowCount, e.Parameters, e.IsSuccess, e.Exception
    };
});
[DapperForge] Get_Students executed in 12ms -> 150 rows
[DapperForge] SLOW: Save_BulkImport executed in 4200ms
[DapperForge] FAILED: sel_Reports — SqlException: Timeout expired (3012ms)

SP Validation

Catch missing stored procedures at startup, not in production. DapperForge queries the database catalog (sys.objects for SQL Server, information_schema.routines for PostgreSQL) to verify existence:

builder.Services.AddDapperForge(options =>
{
    options.ValidateSpOnStartup = true;

    // Fail fast in CI/staging — throw if any SP is missing
    options.FailOnMissingSp = true;

    options.RegisterEntity<Student>();
    options.RegisterEntity<Teacher>();
    options.RegisterEntity<Order>();

    // Or scan an assembly
    options.RegisterEntitiesFromAssembly(typeof(Student).Assembly);
});
[DapperForge] SP Validation: Checking 3 registered entities...
[DapperForge] SP Validated: Get_Students (for entity 'Student')
[DapperForge] SP Validated: Save_Students (for entity 'Student')
[DapperForge] SP Missing: Remove_Students (for entity 'Student')

Multi-Database

DapperForge uses a provider-specific ISpCommandBuilder to generate the correct syntax for each database:

options.Provider = DatabaseProvider.SqlServer;   // CommandType.StoredProcedure → EXEC sp_name @param
options.Provider = DatabaseProvider.PostgreSQL;   // CommandType.Text → SELECT * FROM sp_name(@param)

SQL Server uses ADO.NET's native CommandType.StoredProcedure. PostgreSQL generates SELECT * FROM function_name(@p1, @p2) text commands, since Npgsql does not support CommandType.StoredProcedure reliably.


Configuration

Option Type Default Description
ConnectionString string "" Database connection string
Provider DatabaseProvider SqlServer SQL Server or PostgreSQL
SetConvention() builder Get_/Save_/Remove_ SP naming convention
MapEntity<T>(name) per-entity TypeName + "s" Override SP entity name
EntityNameResolver Func<Type, string> t => t.Name + "s" Global entity name resolver
EnableDiagnostics bool false Enable query logging
SlowQueryThreshold TimeSpan 2s Slow query warning threshold
OnQueryExecuted Action<QueryEvent> null Post-execution callback
ValidateSpOnStartup bool false Validate SPs against DB on startup
FailOnMissingSp bool false Throw on missing SPs (requires ValidateSpOnStartup)

Entity Name Resolution

By default, DapperForge resolves entity names using naive pluralization (TypeName + "s"). This works for most English entity names:

Type Resolved Name SP Name
Student Students Get_Students
Order Orders Save_Orders

However, irregular plurals will not be correct (PersonPersons, StatusStatuss). Use MapEntity<T>() or EntityNameResolver for these cases:

options.MapEntity<Person>("People");
options.MapEntity<Status>("Statuses");

// Or use a library like Humanizer for global resolution
options.EntityNameResolver = type => type.Name.Pluralize();

Thread Safety

IForgeConnection is not thread-safe. Each instance wraps a single ADO.NET connection and must not be shared across concurrent async operations.

// WRONG — concurrent access to the same connection
await Task.WhenAll(
    forge.GetAsync<Student>(),
    forge.GetAsync<Teacher>()   // may corrupt connection state
);

// CORRECT — sequential access
var students = await forge.GetAsync<Student>();
var teachers = await forge.GetAsync<Teacher>();

DapperForge registers IForgeConnection as Scoped by default, which means each HTTP request gets its own connection instance. This is the recommended pattern.


What DapperForge Is NOT

Need Use Instead
Auto CRUD / table access Dapper.Contrib
LINQ queries EF Core
Migrations DbUp or EF Migrations
Caching Your preferred caching layer
Connection pooling ADO.NET handles this

This is intentional. DapperForge does one thing well and complements your existing stack.


Performance

DapperForge adds near-zero overhead on top of raw Dapper. Benchmarked with BenchmarkDotNet on SQLite in-memory to isolate framework cost from network I/O:

Operation Raw Dapper DapperForge Overhead Memory
Query (80 rows) 58.0 us 60.1 us +3.6% 0 extra bytes
Single row 4.54 us 4.53 us ~0% 0 extra bytes
Scalar 1.94 us 1.95 us ~0% 0 extra bytes

Zero additional memory allocations across all operations. The convention engine resolves SP names at negligible cost.

# Run benchmarks yourself
dotnet run -c Release --project benchmarks/DapperForge.Benchmarks

Born From Production

DapperForge was extracted from the data access layer of an enterprise education platform serving 1,900+ daily users across 4+ years of continuous production use. Every API was shaped by real-world needs — not hypothetical use cases.


Project Structure

src/DapperForge/
  Configuration/    ForgeOptions, DatabaseProvider, ConventionBuilder
  Conventions/      SP naming engine + entity name resolver
  Execution/        SpExecutor, ISpCommandBuilder, provider-specific builders
  Transaction/      Auto + manual transaction support
  Diagnostics/      Structured logging, QueryEvent, slow query detection
  Validation/       ISpValidator, SqlServer/Postgres catalog queries
  Extensions/       DI registration + SP validation hosted service

Roadmap

  • Bulk executionSaveManyAsync<T>(IEnumerable<T>) with single transaction
  • Retry policies — configurable retry with Polly integration
  • Connection-per-call mode — option to create fresh connections instead of scoped
  • SP result caching — optional in-memory cache with TTL per SP
  • GitHub Actions CI — automated build, test, and NuGet publish pipeline
  • Source generator — compile-time SP name validation

Have an idea? Open an issue.


Contributing

  1. Fork the repo
  2. Create your branch (git checkout -b feature/your-feature)
  3. Write tests for your changes
  4. All tests must pass (dotnet test)
  5. Open a Pull Request

License

MIT -- see LICENSE for details.

Releases

No releases published

Packages

No packages published

Languages