Skip to main content

Overview

The Azure Table Storage module provides patterns for working with Azure Table Storage, a NoSQL cloud-based data storage service designed for storing structured, semi-structured, or unstructured data at scale. This module consumes your Domain Model from the Domain Designer and generates a complete Azure Table Storage implementation including repositories, table entities, and Unit of Work patterns.

Installation

Intent.Azure.TableStorage

What is Azure Table Storage?

Azure Table Storage is a cloud-based NoSQL data store that:
  • Stores large amounts of structured data
  • Provides highly available and scalable storage
  • Uses a key-based schema for flexible data access
  • Offers automatic load balancing
  • Integrates seamlessly with other Azure services

Use Cases

IoT Data

Store time-series telemetry data

User Data

Profile information and preferences

Logs & Events

Application logs and audit trails

Metadata

Large amounts of flexible metadata

What’s Generated

This module generates:
  • Unit of Work - Transaction management
  • Table Entities - Azure Table Storage entities
  • Repositories - Data access layer
  • Configuration - Dependency injection setup
  • App Settings - Connection string configuration

Domain Modeling

Composite Primary Keys

Unlike traditional databases, Azure Table Storage uses composite keys consisting of:
  • PartitionKey - Logical grouping of entities
  • RowKey - Unique identifier within a partition
Together, they form the primary key: [PartitionKey, RowKey]

Example Domain Model

// Domain Model in Intent Architect
public class Customer
{
    // Maps to PartitionKey
    public string Region { get; set; }
    
    // Maps to RowKey
    public string CustomerId { get; set; }
    
    // Additional properties
    public string Name { get; set; }
    public string Email { get; set; }
    public DateTime CreatedDate { get; set; }
}
Design your PartitionKey to distribute data evenly across partitions for optimal performance. Common strategies include region, date ranges, or customer segments.

Compositional Relationships

Table Storage data is flat. If you model compositional relationships in your domain:
  • Child objects are JSON serialized into the parent entity
  • Stored as a single column in the flat table
Example:
public class Order
{
    public string PartitionKey { get; set; }  // CustomerId
    public string RowKey { get; set; }        // OrderId
    
    // Complex object - will be JSON serialized
    public List<OrderItem> Items { get; set; }
}

// Stored in Azure Table as:
// PartitionKey: "CUST001"
// RowKey: "ORD-12345"
// Items: "[{\"ProductId\":\"PRD001\",\"Quantity\":2},...]"

Generated Code

Repository Interface

public interface ICustomerRepository
{
    Task<Customer> FindByIdAsync(
        string partitionKey, 
        string rowKey, 
        CancellationToken cancellationToken = default);
    
    Task<List<Customer>> FindAllAsync(CancellationToken cancellationToken = default);
    
    Task<Customer> AddAsync(
        Customer entity, 
        CancellationToken cancellationToken = default);
    
    Task UpdateAsync(
        Customer entity, 
        CancellationToken cancellationToken = default);
    
    Task DeleteAsync(
        Customer entity, 
        CancellationToken cancellationToken = default);
    
    Task<CursorPagedList<Customer>> FindAllAsync(
        int pageSize,
        string continuationToken = null,
        CancellationToken cancellationToken = default);
}

Table Entity

public class CustomerTableEntity : ITableEntity
{
    public string PartitionKey { get; set; }  // Region
    public string RowKey { get; set; }        // CustomerId
    public DateTimeOffset? Timestamp { get; set; }
    public ETag ETag { get; set; }
    
    // Business properties
    public string Name { get; set; }
    public string Email { get; set; }
    public DateTime CreatedDate { get; set; }
}

Unit of Work

public interface ITableStorageUnitOfWork : IUnitOfWork
{
    Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

public class TableStorageUnitOfWork : ITableStorageUnitOfWork
{
    private readonly TableServiceClient _client;
    
    public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        // Batch operations for efficiency
        await ExecuteBatchOperationsAsync(cancellationToken);
    }
}

Configuration

Local Development (Azurite)

appsettings.Development.json:
{
  "ConnectionStrings": {
    "TableStorage": "UseDevelopmentStorage=true"
  }
}

Production

appsettings.json:
{
  "ConnectionStrings": {
    "TableStorage": "DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=mykey;EndpointSuffix=core.windows.net"
  }
}

Using Managed Identity

{
  "TableStorage": {
    "ServiceUri": "https://myaccount.table.core.windows.net"
  }
}
services.AddSingleton(sp =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    var serviceUri = new Uri(config["TableStorage:ServiceUri"]);
    return new TableServiceClient(serviceUri, new DefaultAzureCredential());
});

Usage Examples

Customer Repository

public class CustomerService
{
    private readonly ICustomerRepository _repository;
    private readonly ITableStorageUnitOfWork _unitOfWork;

    public CustomerService(
        ICustomerRepository repository,
        ITableStorageUnitOfWork unitOfWork)
    {
        _repository = repository;
        _unitOfWork = unitOfWork;
    }

    public async Task CreateCustomerAsync(
        CreateCustomerCommand command,
        CancellationToken cancellationToken)
    {
        var customer = new Customer
        {
            Region = command.Region,
            CustomerId = Guid.NewGuid().ToString(),
            Name = command.Name,
            Email = command.Email,
            CreatedDate = DateTime.UtcNow
        };

        await _repository.AddAsync(customer, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);
    }

    public async Task<Customer> GetCustomerAsync(
        string region,
        string customerId,
        CancellationToken cancellationToken)
    {
        return await _repository.FindByIdAsync(
            region, 
            customerId, 
            cancellationToken);
    }
}

Querying by Partition

public async Task<List<Customer>> GetCustomersByRegionAsync(
    string region,
    CancellationToken cancellationToken)
{
    var filter = TableClient.CreateQueryFilter($"PartitionKey eq {region}");
    
    var customers = new List<Customer>();
    await foreach (var entity in _tableClient.QueryAsync<CustomerTableEntity>(
        filter, 
        cancellationToken: cancellationToken))
    {
        customers.Add(MapToCustomer(entity));
    }
    
    return customers;
}

Pagination

public async Task<CursorPagedList<Customer>> GetCustomersPagedAsync(
    int pageSize,
    string continuationToken,
    CancellationToken cancellationToken)
{
    return await _repository.FindAllAsync(
        pageSize,
        continuationToken,
        cancellationToken);
}

// Usage
var page1 = await GetCustomersPagedAsync(100, null, cancellationToken);
var page2 = await GetCustomersPagedAsync(100, page1.ContinuationToken, cancellationToken);

Batch Operations

public async Task BulkInsertCustomersAsync(
    List<Customer> customers,
    CancellationToken cancellationToken)
{
    // Group by partition key for batch operations
    var batches = customers
        .GroupBy(c => c.Region)
        .Select(g => g.ToList());

    foreach (var batch in batches)
    {
        var tableBatch = new List<TableTransactionAction>();
        
        foreach (var customer in batch.Take(100)) // Max 100 per batch
        {
            var entity = MapToTableEntity(customer);
            tableBatch.Add(new TableTransactionAction(
                TableTransactionActionType.Add, 
                entity));
        }

        await _tableClient.SubmitTransactionAsync(
            tableBatch, 
            cancellationToken);
    }
}

Query Patterns

Filter Expressions

// Exact match
var filter = TableClient.CreateQueryFilter($"Email eq {email}");

// Range query
var filter = TableClient.CreateQueryFilter(
    $"CreatedDate ge {startDate} and CreatedDate le {endDate}");

// Contains (limited support)
var filter = TableClient.CreateQueryFilter($"Name ge {'A'} and Name lt {'B'}");

// Multiple conditions
var filter = TableClient.CreateQueryFilter(
    $"PartitionKey eq {region} and Status eq {'Active'}");

Supported Operators

OperatorDescriptionExample
eqEqualStatus eq 'Active'
neNot equalStatus ne 'Deleted'
gtGreater thanPrice gt 100
geGreater than or equalCreatedDate ge datetime'2024-01-01'
ltLess thanStock lt 10
leLess than or equalAge le 65
andLogical ANDRegion eq 'US' and Status eq 'Active'
orLogical ORStatus eq 'New' or Status eq 'Pending'
notLogical NOTnot (Status eq 'Deleted')
Table Storage does not support LIKE or contains operations. Use partition key and row key prefixes for efficient filtering.

Local Development

Azurite Setup

Install:
npm install -g azurite
Run:
azurite --location ./azurite
Default Endpoints:
  • Blob: http://127.0.0.1:10000
  • Queue: http://127.0.0.1:10001
  • Table: http://127.0.0.1:10002

Azure Storage Explorer

Browse and manage tables using Azure Storage Explorer:
  1. Download and install
  2. Connect to Azurite or your Azure account
  3. Navigate to Tables
  4. View, query, and modify entities

Performance Optimization

Partition Key Design

Good Partition Key Examples:
// Time-based (if queries are time-ranged)
PartitionKey = date.ToString("yyyy-MM");

// Geographic
PartitionKey = user.Country;

// Customer segment
PartitionKey = customer.Tier; // "Premium", "Standard", "Basic"

// Hash-based (for even distribution)
PartitionKey = customerId.GetHashCode().ToString("X").Substring(0, 2);
Avoid Hot Partitions:
// Bad - All entities in one partition
PartitionKey = "Customer";

// Bad - Timestamp creates sequential hot partition
PartitionKey = DateTime.UtcNow.ToString("yyyy-MM-dd-HH-mm-ss");

Query Optimization

Fastest queries specify both PartitionKey and RowKey:
// Fastest - O(1)
var customer = await _tableClient.GetEntityAsync<CustomerTableEntity>(
    partitionKey: "US-West",
    rowKey: "CUST-12345");
Next best - queries within a single partition:
// Fast - scans single partition
var filter = TableClient.CreateQueryFilter(
    $"PartitionKey eq {'US-West'} and Status eq {'Active'}");
Avoid queries without PartitionKey filter:
// Slow - full table scan
var filter = TableClient.CreateQueryFilter($"Email eq {email}");
Select only required properties:
var query = _tableClient.QueryAsync<CustomerTableEntity>(
    filter: filter,
    select: new[] { "Name", "Email" });

Batch Operations

Batch operations improve performance:
public async Task BatchUpdateAsync(
    List<Customer> customers,
    CancellationToken cancellationToken)
{
    // Must be same partition
    var partitionKey = customers.First().Region;
    
    var batch = new List<TableTransactionAction>();
    foreach (var customer in customers.Take(100))
    {
        var entity = MapToTableEntity(customer);
        batch.Add(new TableTransactionAction(
            TableTransactionActionType.UpdateMerge,
            entity));
    }
    
    await _tableClient.SubmitTransactionAsync(batch, cancellationToken);
}
Batch Limitations:
  • Maximum 100 operations per batch
  • All entities must be in the same partition
  • Total batch size cannot exceed 4 MB

Best Practices

Design partition keys for:
  • Even distribution of data
  • Query patterns alignment
  • Avoiding hot partitions
  • Logical data grouping
Consider using composite strategies:
// Combine region and time
PartitionKey = $"{region}_{date:yyyyMM}";
Use meaningful row keys:
  • Natural identifiers when possible
  • Sortable for range queries
  • Include timestamps for ordering
// Time-sortable
RowKey = $"{DateTime.MaxValue.Ticks - DateTime.UtcNow.Ticks:D19}";

// Composite
RowKey = $"{customerId}_{orderId}";
Keep entities small:
  • Maximum entity size: 1 MB
  • Maximum property size: 64 KB (string)
  • Prefer denormalization over large entities
  • Consider splitting large objects across multiple entities
Use ETags for optimistic concurrency:
try
{
    await _tableClient.UpdateEntityAsync(
        entity,
        entity.ETag,
        TableUpdateMode.Replace);
}
catch (RequestFailedException ex) when (ex.Status == 412)
{
    // Concurrency conflict - entity was modified
    // Reload and retry
}
Handle common errors:
try
{
    await _repository.AddAsync(customer, cancellationToken);
}
catch (RequestFailedException ex)
{
    switch (ex.Status)
    {
        case 404:
            // Table doesn't exist
            await CreateTableAsync();
            break;
        case 409:
            // Entity already exists
            await _repository.UpdateAsync(customer, cancellationToken);
            break;
        case 412:
            // Precondition failed (ETag mismatch)
            throw new ConcurrencyException();
        default:
            throw;
    }
}

Monitoring

Application Insights

public class MonitoredCustomerRepository : ICustomerRepository
{
    private readonly ICustomerRepository _inner;
    private readonly TelemetryClient _telemetry;

    public async Task<Customer> FindByIdAsync(
        string partitionKey,
        string rowKey,
        CancellationToken cancellationToken)
    {
        var stopwatch = Stopwatch.StartNew();
        try
        {
            var result = await _inner.FindByIdAsync(
                partitionKey, rowKey, cancellationToken);
            
            _telemetry.TrackDependency(
                "Azure Table Storage",
                "FindById",
                stopwatch.Elapsed,
                success: true);
            
            return result;
        }
        catch (Exception ex)
        {
            _telemetry.TrackException(ex);
            throw;
        }
    }
}

Key Metrics

  • Request latency
  • Throttling events (HTTP 503)
  • Success/failure rates
  • Entity size distribution
  • Partition distribution

Migration from Other Data Stores

From SQL Database

Changes needed:
  1. Redesign schema around partition/row keys
  2. Denormalize data (no joins)
  3. Implement application-level transactions
  4. Adjust query patterns

From Cosmos DB

Similarities:
  • Both are NoSQL
  • Schema-flexible
  • Partition-based
Key differences:
  • Table Storage: Simpler, lower cost, key-value only
  • Cosmos DB: Richer queries, global distribution, multiple APIs

Resources

Table Storage Docs

Official documentation

Design Patterns

Table design guide

Azurite

Local development emulator

Performance Guide

Optimization best practices

Build docs developers (and LLMs) love