The Evolution of Tuxedo: Dapper Done Right
How Noundry.Tuxedo grew from a simple Dapper wrapper to a full-featured data access toolkit with QueryBuilder, code-first migrations, auditing, and fake data generation.
Dapper is one of the best things to happen to C# data access. It's fast, it's simple, and it doesn't try to be an ORM when you just want to run SQL. But after using it across dozens of projects, I kept writing the same wrapper code over and over.
That's why Tuxedo exists. It's not a replacement for Dapper. It's everything I wished Dapper included out of the box.
The Ecosystem Today
Tuxedo has grown into a suite of packages that work together:
- Noundry.Tuxedo - Core ORM with SQL-aligned methods and full Dapper compatibility
- Noundry.Tuxedo.Exceptions - Strongly-typed database exceptions
- Noundry.Tuxedo.Auditor - Automatic CRUD audit logging
- Noundry.Tuxedo.Bowtie - Code-first database migrations
- Noundry.Tuxedo.Cufflink - Native fake data generation
Each targets .NET 8.0, 9.0, and 10.0 with support for SQL Server, PostgreSQL, MySQL, and SQLite.
QueryBuilder: The Heart of Tuxedo
The fluent QueryBuilder is what makes Tuxedo shine for dynamic queries. Instead of string concatenation or complex conditional SQL building, you get type-safe, composable queries:
var products = await new QueryBuilder<Product>(TuxedoDialect.SqlServer)
.SelectAll()
.Where(p => p.Price > 10 && p.IsActive)
.WhereIn(p => p.CategoryId, new[] { 1, 2, 3 })
.WhereBetween(p => p.CreatedAt, startDate, endDate)
.OrderBy(p => p.Name)
.ThenByDescending(p => p.Price)
.Page(pageIndex: 2, pageSize: 20)
.ToListAsync(connection);
The builder supports everything you'd expect: joins, grouping, aggregations, raw SQL escape hatches, and dialect-specific pagination. But what I'm most proud of is how readable the code stays even for complex queries:
var query = new QueryBuilder<Order>()
.SelectAll()
.InnerJoin<Customer>((o, c) => o.CustomerId == c.Id)
.LeftJoin<OrderItem>((o, oi) => o.Id == oi.OrderId)
.Where(o => o.Status != "Cancelled")
.WhereNotNull(o => o.ShippedAt)
.GroupBy(o => o.CustomerId)
.Having(o => o.TotalAmount > 1000);
One important note: QueryBuilder instances are not thread-safe and not reusable. Create a new instance for each query. This is by design to keep the API simple and avoid hidden state bugs.
Code-First Migrations with Bowtie
Bowtie takes a fundamentally different approach to migrations than most tools. Instead of SQL files or C# migration classes, your entity definitions are the schema. You define everything through attributes on your models:
[Table("orders")]
[TableOrder(2)] // Create after users table
public class Order
{
[Key]
public int Id { get; set; }
[Column("customer_id")]
[ForeignKey("users", ReferencedColumn = "id",
OnDelete = ReferentialAction.Cascade)]
public int CustomerId { get; set; }
[Index("IX_Orders_Date", IndexType = IndexType.BTree)]
public DateTime OrderDate { get; set; }
[Unique("UQ_Orders_Number")]
public string OrderNumber { get; set; } = string.Empty;
[CheckConstraint("quantity > 0", Name = "CK_Orders_Quantity")]
public int Quantity { get; set; }
[DefaultValue("GETDATE()", IsRawSql = true)]
public DateTime CreatedAt { get; set; }
}
Bowtie uses reflection to analyze your entities, compare them to the current database schema, and generate the necessary DDL. The [TableOrder] attribute solves the foreign key ordering problem by ensuring tables are created in dependency order.
For CI/CD pipelines, there's a CLI that can generate migration scripts, run dry-runs, and detect potential data loss:
# Preview changes without executing
bowtie sync --dry-run
# Generate DDL script for review
bowtie sync --output schema.sql
# Force mode for automated pipelines
bowtie sync --force
The data loss analyzer will warn you about risky operations like column drops, type changes, or length reductions before they happen.
Fake Data Generation with Cufflink
Every project needs test data. Cufflink is a native fake data generator that understands your Tuxedo models:
var engine = new CufflinkEngine();
// Generate 1000 records for all entities in assembly
await engine.GenerateFakeAsync(
assembly: typeof(Product).Assembly,
recordCount: 1000,
connectionString: "your-connection-string",
provider: DatabaseProvider.SqlServer,
batchSize: 500);
// Or seed specific data
var seedData = new Dictionary<Type, List<object>>
{
[typeof(Category)] = new()
{
new Category { Id = Guid.NewGuid(), Name = "Electronics" },
new Category { Id = Guid.NewGuid(), Name = "Books" }
}
};
await engine.SeedAsync(seedData, connectionString, provider);
Cufflink respects foreign key relationships and generates realistic data based on column names and types. It's especially useful for load testing and development environments.
Exception Translation
Database exceptions are notoriously cryptic. Tuxedo.Exceptions translates them into strongly-typed exceptions you can actually work with:
var translatingConnection = connection.WithExceptionTranslation();
try
{
await translatingConnection.InsertAsync(user);
}
catch (UniqueConstraintException ex)
{
// ex.ConstraintName, ex.ConstraintProperties
return BadRequest($"Duplicate value: {ex.ConstraintName}");
}
catch (ReferenceConstraintException ex)
{
// ex.ConstraintName - FK violation
return BadRequest("Invalid reference");
}
catch (MaxLengthExceededException ex)
{
// ex.ColumnName, ex.MaxLength
return BadRequest($"{ex.ColumnName} too long (max {ex.MaxLength})");
}
Works across SQL Server, PostgreSQL, MySQL, and SQLite with consistent exception types regardless of the underlying database.
Audit Logging
For regulated industries or simply wanting to know who changed what, Tuxedo.Auditor wraps your connection and logs every CRUD operation:
var auditedConnection = connection.WithAuditing(config =>
{
config.UserProvider = new HttpContextAuditUserProvider(httpContextAccessor);
config.DataProvider = new SqlServerAuditDataProvider(connection);
});
// All operations now logged with old/new values, user, timestamp
await auditedConnection.UpdateAsync(product);
Audit events capture the operation type, table name, entity ID, old values, new values, changed columns, username, and timestamp. Everything you need for compliance or debugging.
Dapper Compatibility
If you have existing Dapper code, migrating is trivial. Most APIs are identical:
// Before (Dapper)
using Dapper;
using Dapper.Contrib.Extensions;
// After (Tuxedo)
using Noundry.Tuxedo;
using Noundry.Tuxedo.Contrib;
// These work identically:
connection.Query<T>(sql, parameters)
connection.QueryAsync<T>(sql, parameters)
connection.Get<T>(id)
connection.Insert(entity)
connection.Update(entity)
connection.Delete(entity)
You can migrate incrementally, adding Tuxedo features like QueryBuilder and Bowtie to existing codebases without rewriting working code.
Thread Safety in v0.8.0
The latest release focused on thread safety and security:
- Property caches now use atomic
ConcurrentDictionary.GetOrAdd() - All async methods use
ConfigureAwait(false)to prevent deadlocks - SQLite introspector validates identifiers against injection attacks
- Audit table creation uses
Interlocked.CompareExchangefor thread safety
These are the kinds of details that only matter in production under load. We've found them so you don't have to.
The Bottom Line
Tuxedo isn't trying to be Entity Framework. It's for developers who like Dapper's philosophy of staying close to SQL but want more batteries included:
- Type-safe query building without string concatenation
- Schema management from your C# models
- Meaningful exceptions instead of cryptic database errors
- Built-in audit logging for compliance
- Test data generation that understands your schema
If you're using Dapper today and feeling the pain of writing the same boilerplate, give Tuxedo a look.
Tuxedo is available on NuGet as part of the Noundry platform. Check the documentation or reach out with questions at george@georgerios.com.