A .NET library that provides abstract base classes and repository patterns for Azure Table Storage, simplifying data access and operations with a clean, testable interface.
Install the package via NuGet Package Manager:
# Package Manager Console
Install-Package TableStorage.Abstracts
# .NET CLI
dotnet add package TableStorage.Abstracts
NuGet Package: TableStorage.Abstracts
- Repository Pattern: Clean abstraction over Azure Table Storage operations
- Query Operations: Find single entities, collections, and paginated results
- CRUD Operations: Full Create, Read, Update, Delete support with async/await
- Batch Processing: Efficient bulk insert, update, and delete operations
- Dependency Injection: Built-in support for Microsoft.Extensions.DependencyInjection
- Auto-Initialization: Automatic table creation on first use
- Key Generation: Automatic RowKey generation using ULID
- Multi-Framework: Supports .NET Standard 2.0, .NET 8.0, and .NET 9.0
Create an entity class that inherits from TableEntityBase
or implements ITableEntity
:
public class User : TableEntityBase
{
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
}
Register the repository services in your Program.cs
or Startup.cs
:
// Using connection string from configuration
builder.Services.AddTableStorageRepository("AzureStorage");
// Or with direct connection string
builder.Services.AddTableStorageRepository("UseDevelopmentStorage=true");
Configuration Example (appsettings.json
):
{
"AzureStorage": "UseDevelopmentStorage=true"
}
Inject and use ITableRepository<T>
in your services:
public class UserService
{
private readonly ITableRepository<User> _userRepository;
public UserService(ITableRepository<User> userRepository)
{
_userRepository = userRepository;
}
public async Task<User> CreateUserAsync(string name, string email)
{
var user = new User { Name = name, Email = email };
return await _userRepository.CreateAsync(user);
}
public async Task<User?> GetUserAsync(string rowKey, string partitionKey)
{
return await _userRepository.FindAsync(rowKey, partitionKey);
}
}
Register with connection string from configuration:
services.AddTableStorageRepository("AzureStorage");
Register with direct connection string:
services.AddTableStorageRepository("UseDevelopmentStorage=true");
Resolve ITableRepository<T>
:
var repository = serviceProvider.GetRequiredService<ITableRepository<User>>();
Resolve TableServiceClient
:
var tableServiceClient = serviceProvider.GetRequiredService<TableServiceClient>();
Create a custom repository by inheriting from TableRepository<T>
:
public class UserRepository : TableRepository<User>
{
public UserRepository(ILoggerFactory logFactory, TableServiceClient tableServiceClient)
: base(logFactory, tableServiceClient)
{ }
protected override void BeforeSave(User entity)
{
// Use email as partition key for better data distribution
entity.PartitionKey = entity.Email;
base.BeforeSave(entity);
}
// Override default table name (uses typeof(TEntity).Name by default)
protected override string GetTableName() => "UserProfiles";
// Custom business logic methods
public async Task<IReadOnlyList<User>> FindActiveUsersAsync()
{
return await FindAllAsync(u => u.IsActive);
}
public async Task<User?> FindByEmailAsync(string email)
{
return await FindOneAsync(u => u.Email == email);
}
}
Register your custom repository:
services.AddTableStorageRepository("AzureStorage");
services.AddScoped<UserRepository>();
Azure Table Storage uses a two-part key system that's fundamental to performance and scalability. Understanding these keys is crucial for designing efficient table storage solutions.
-
RowKey: Unique identifier within a partition
- Must be unique within the partition
- Automatically generated using ULID if not explicitly set
- Combined with PartitionKey forms the primary key
- Case-sensitive string (max 1KB)
-
PartitionKey: Logical grouping for related entities
- Determines physical storage distribution
- Entities with the same PartitionKey are stored together
- Defaults to RowKey value if not explicitly set
- Case-sensitive string (max 1KB)
Automatic key generation (recommended for simple scenarios):
var user = new User
{
Name = "John Doe",
Email = "[email protected]"
// RowKey will be auto-generated using ULID
// PartitionKey will default to the generated RowKey value
};
await repository.CreateAsync(user);
// Result: RowKey = "01ARZ3NDEKTSV4RRFFQ69G5FAV", PartitionKey = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
Explicit key assignment for custom partitioning:
var user = new User
{
Name = "John Doe",
Email = "[email protected]",
PartitionKey = "Department_Engineering", // Group by department
RowKey = "user_john_doe" // Custom identifier
};
Strategic partitioning for better performance:
// Time-based partitioning (good for log data)
var eventTime = DateTimeOffset.UtcNow;
var logEntry = new LogEvent
{
Message = "User login",
PartitionKey = KeyGenerator.GeneratePartitionKey(eventTime), // 5-minute intervals with reverse chronological ordering
RowKey = KeyGenerator.GenerateRowKey(eventTime) // ULID with reverse chronological ordering
};
// Geographic partitioning
var order = new Order
{
ProductName = "Widget",
PartitionKey = "Region_US_West", // Group by region
RowKey = $"order_{Guid.NewGuid()}"
};
Query Performance:
- Queries filtering by both PartitionKey and RowKey are fastest (point queries)
- Queries filtering by PartitionKey only are efficient (partition scans)
- Queries without PartitionKey scan the entire table (avoid when possible)
// Fastest: Point query (both keys)
var user = await repository.FindAsync("user_001", "Department_Engineering");
// Fast: Partition scan
var deptUsers = await repository.FindAllAsync(u => u.PartitionKey == "Department_Engineering");
// Slow: Table scan (avoid if possible)
var activeUsers = await repository.FindAllAsync(u => u.IsActive);
Find an entity by row and partition key (both required by Azure Table Storage):
var user = await repository.FindAsync(rowKey, partitionKey);
if (user != null)
{
Console.WriteLine($"Found user: {user.Name}");
}
Find all entities matching a filter expression:
var activeUsers = await repository.FindAllAsync(u => u.IsActive);
Find a single entity by filter (returns first match):
var user = await repository.FindOneAsync(u => u.Email == "[email protected]");
Azure Table Storage supports forward-only paging using continuation tokens:
var pageResult = await repository.FindPageAsync(
filter: u => u.IsActive,
pageSize: 20);
Console.WriteLine($"Found {pageResult.Items.Count} users");
// Loop through all pages
while (!string.IsNullOrEmpty(pageResult.ContinuationToken))
{
pageResult = await repository.FindPageAsync(
filter: u => u.IsActive,
continuationToken: pageResult.ContinuationToken,
pageSize: 20);
Console.WriteLine($"Next page: {pageResult.Items.Count} users");
}
Create a new entity:
var user = new User
{
Name = "John Doe",
Email = "[email protected]",
IsActive = true
};
var createdUser = await repository.CreateAsync(user);
Console.WriteLine($"Created user with ID: {createdUser.RowKey}");
Update an existing entity:
user.Name = "John Smith";
var updatedUser = await repository.UpdateAsync(user);
Save (create or update) an entity:
var savedUser = await repository.SaveAsync(user);
Delete an entity:
await repository.DeleteAsync(user);
// Or delete by keys
await repository.DeleteAsync(rowKey, partitionKey);
Perform bulk operations efficiently using either the core BatchAsync
method or the convenient extension methods:
Note: Azure Table Storage batch operations are limited to 100 entities per batch and all entities must share the same PartitionKey. The batch methods automatically handle these limitations by grouping entities by PartitionKey and chunking them into batches of 100 items.
The fundamental batch operation method with explicit transaction type:
var users = new List<User>
{
new() { Name = "User 1", Email = "[email protected]" },
new() { Name = "User 2", Email = "[email protected]" },
new() { Name = "User 3", Email = "[email protected]" }
};
// Insert new entities
await repository.BatchAsync(users, TableTransactionActionType.Add);
// Update existing entities
await repository.BatchAsync(users, TableTransactionActionType.UpdateReplace);
// Merge changes (partial updates)
await repository.BatchAsync(users, TableTransactionActionType.UpdateMerge);
// Delete entities
await repository.BatchAsync(users, TableTransactionActionType.Delete);
Use the convenient extension methods for common batch operations:
var users = new List<User>
{
new() { Name = "User 1", Email = "[email protected]" },
new() { Name = "User 2", Email = "[email protected]" },
new() { Name = "User 3", Email = "[email protected]" }
};
// Create multiple entities (equivalent to TableTransactionActionType.Add)
var createdCount = await repository.CreateBatchAsync(users);
Console.WriteLine($"Created {createdCount} users");
// Update multiple entities (equivalent to TableTransactionActionType.UpdateReplace)
var updatedCount = await repository.UpdateBatchAsync(users);
Console.WriteLine($"Updated {updatedCount} users");
// Save multiple entities - insert if new, update if exists (equivalent to TableTransactionActionType.UpsertReplace)
var savedCount = await repository.SaveBatchAsync(users);
Console.WriteLine($"Saved {savedCount} users");
// Delete multiple entities
var deletedCount = await repository.DeleteBatchAsync(users);
Console.WriteLine($"Deleted {deletedCount} users");
Delete entities by filter expression with automatic pagination:
// Delete all inactive users (processes in pages to limit memory usage)
var deletedCount = await repository.DeleteBatchAsync(u => !u.IsActive);
Console.WriteLine($"Deleted {deletedCount} inactive users");
// Delete using OData filter query
var deletedCount = await repository.DeleteBatchAsync("IsActive eq false");
Console.WriteLine($"Deleted {deletedCount} inactive users");
Performance Tip: The filter-based delete methods automatically handle pagination to prevent memory issues when deleting large numbers of entities. Each page is processed using batch operations for optimal performance.
Override the default ULID key generation:
public class CustomRepository : TableRepository<User>
{
public override string NewRowKey()
{
return Guid.NewGuid().ToString();
}
}
Tables are automatically created on first use. To manually initialize:
var tableClient = await repository.GetClientAsync();
await tableClient.CreateIfNotExistsAsync();
Access the underlying Azure Table Storage client:
var tableServiceClient = serviceProvider.GetRequiredService<TableServiceClient>();
var tables = tableServiceClient.QueryTablesAsync();
Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
This project is licensed under the MIT License - see the LICENSE file for details.