ASCDataAccessLibrary 4.1.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package ASCDataAccessLibrary --version 4.1.0
                    
NuGet\Install-Package ASCDataAccessLibrary -Version 4.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="ASCDataAccessLibrary" Version="4.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ASCDataAccessLibrary" Version="4.1.0" />
                    
Directory.Packages.props
<PackageReference Include="ASCDataAccessLibrary" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add ASCDataAccessLibrary --version 4.1.0
                    
#r "nuget: ASCDataAccessLibrary, 4.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package ASCDataAccessLibrary@4.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=ASCDataAccessLibrary&version=4.1.0
                    
Install as a Cake Addin
#tool nuget:?package=ASCDataAccessLibrary&version=4.1.0
                    
Install as a Cake Tool

ASCDataAccess v4.1 — Complete Getting Started Guide

NuGet Package: ASCDataAccess · Namespace: ASCTableStorage · Version: 4.1.0
Developed by: Answer Sales Calls Inc. (ASC)
Platform: Azure Table Storage · Azure Blob Storage · net9.0 (compatible with .NET 9 and .NET 10 · NuGet TFM fallback for .NET 6–8)
SDK: Azure.Data.Tables v12 (modern SDK — no legacy Microsoft.Azure.Cosmos.Table dependency)


Table of Contents

  1. What Is ASCDataAccess?
  2. The Case For This Library — Cost, Scale, and the ORM Gap
  3. Installation and One-Time Configuration
  4. The Data Model — RowKey, PartitionKey, and Why They Matter
  5. Azure Table Storage Hard Limits — What Every Developer and AI Must Know
  6. JSON Fields vs. Related Entities — The Most Important Schema Decision
  7. Defining Entities and Writing Your First Data Operations
  8. Lambda Expressions — What Works Server-Side and What Does Not
  9. When You Do Need DataAccess<T> Directly
  10. Choosing the Right Pattern — Quick Reference
  11. Batch Operations
  12. Pagination
  13. Dynamic Entities — Schema-Free Storage
  14. Azure Blob Storage with Lambda Tag Filtering
  15. Queue Management with StateList — Resumable Work Queues
  16. Session Management — Taking Over All Session State in Your App
  17. Logging — A First-Class ILogger Provider
  18. Structured Error Records — ErrorLogData
  19. Advanced: TableOptions, Multi-Account, and Data Migration
  20. Automatic Type Handling: Decimals, Large Strings, Enums, and Complex Types
  21. Complete API Reference
  22. Design Patterns, Best Practices, and Anti-Patterns

1. What Is ASCDataAccess?

ASCDataAccess is the only production-quality data access framework for Azure Table Storage in the .NET ecosystem. Built and maintained by Answer Sales Calls Inc. (ASC), it gives .NET developers the ORM-style experience they expect — strongly-typed entities, lambda expression queries, automatic serialization, batch processing, pagination, and fluent extension methods — on top of one of the most cost-effective cloud data stores available anywhere.

Azure Table Storage is not a traditional relational database. It is a massively scalable, schemaless key-value NoSQL store that Microsoft runs at planetary scale internally. It is also dramatically cheaper than every alternative. The reason most teams do not use it is not because it cannot handle their workload — it is because working with the raw Azure SDK is verbose, error-prone, and requires developers to solve the same boilerplate problems from scratch on every project.

ASCDataAccess eliminates all of that. It is the bridge between the developer experience you want and the infrastructure cost you cannot get anywhere else.

What this library replaces

Without ASCDataAccess With ASCDataAccess
Manual TableServiceClient / TableClient wiring await new Customer().LoadAsync("id") — done
OData filter strings written by hand await new List<Customer>().LoadAsync(x => x.IsActive == true)
Manual save/update plumbing await customer.SaveAsync()
Manual delete plumbing await customer.DeleteAsync()
Manual batch chunking (100-entity Azure limit) Automatic chunking with progress callbacks
Manual decimal serialization, string chunking, enum handling Transparent and automatic
Writing pagination loops with continuation tokens GetPagedCollectionAsync(pageSize, token)
Per-call credential passing throughout the codebase TableStorage.Configure() once in Program.cs
Separate Blob Storage SDK wiring new AzureBlobs(containerName) — instant access
Building queue and session infrastructure from scratch QueueData<T>, StateList<T>, Session — built-in

Key capabilities at a glance

  • Fluent entity operationsawait new MyEntity().LoadAsync(lambda), .SaveAsync(), .DeleteAsync() directly on your objects — no boilerplate classes needed for everyday use
  • Lambda expression queries with automatic hybrid server-side/client-side splitting
  • Batch insert/update/delete up to any size (auto-chunked at the Azure 100-entity limit)
  • Cursor-based pagination with continuation tokens for large datasets
  • Schema-free dynamic entities (DynamicEntity) for runtime-defined data structures
  • Azure Blob Storage with lambda-based tag filtering, streaming, FFmpeg video compression
  • Persistent resumable work queues (QueueData<T> + StateList<T>) with position checkpointing
  • Azure Table-backed session management for web, desktop, and console apps
  • First-class ILogger provider — standard .NET logging that writes to Azure Table Storage
  • Transparent handling of Azure's 32 KB string limit, decimal storage, enum serialization, and complex type JSON serialization
  • Automatic PartitionKey change detection — moving an entity between partitions is handled transparently

2. The Case For This Library — Cost, Scale, and the ORM Gap

This section exists to answer the question every engineering lead asks before adopting a new data framework: why this, why now, and is it worth it? The answer is grounded in three facts that, taken together, make ASCDataAccess one of the most defensible infrastructure decisions a .NET team can make.


Fact 1: There is no Entity Framework for Azure Table Storage — and there never will be

This is the single most important thing to understand about the landscape ASCDataAccess operates in. Entity Framework Core — the dominant .NET data access framework — does not support Azure Table Storage. Microsoft has never shipped an EF Core provider for it, no community project with meaningful adoption exists, and none is in credible development.

This is not an oversight. It is an architectural reality. Azure Table Storage's OData v2 query model is too limited to back a full EF Core LINQ provider without misleading developers about what is actually executing server-side. Microsoft chose honesty over convenience and left the gap open.

The practical consequence is stark: a .NET developer working with Azure Table Storage has two choices:

  1. Write everything from scratch against the raw Azure.Data.Tables SDK — manually constructing OData filter strings, manually chunking batch operations at 100 entities, manually handling decimal serialization, string chunking, enum conversion, continuation tokens, and credential passing across every call site in the codebase.

  2. Use ASCDataAccess and write idiomatic C# against a clean, typed API.

There is no option 3. The market comparison is not ASCDataAccess vs. Entity Framework Core. It is ASCDataAccess vs. nothing.

Here is what that difference looks like in practice. The same operation, written both ways:

Fetch all active customers for a company — raw Azure SDK:

var serviceClient = new TableServiceClient(connectionString);
var tableClient   = serviceClient.GetTableClient("Customers");
await tableClient.CreateIfNotExistsAsync();

var filter  = "PartitionKey eq 'COMP-001' and IsActive eq true";
var results = new List<CustomerEntity>();

await foreach (var entity in tableClient.QueryAsync<TableEntity>(filter))
{
    var customer = new CustomerEntity
    {
        PartitionKey = entity.GetString("PartitionKey"),
        RowKey       = entity.GetString("RowKey"),
        Name         = entity.GetString("Name"),
        Email        = entity.GetString("Email"),
        IsActive     = entity.GetBoolean("IsActive") ?? false,
        // Decimals stored as long — must manually reverse the ×10000 conversion
        Balance      = entity.GetInt64("Balance") is long raw ? raw / 10000m : 0m,
        // Enums stored as strings — must manually parse
        Status       = Enum.TryParse<CustomerStatus>(
                           entity.GetString("Status"), out var s) ? s : CustomerStatus.Active,
        CreatedDate  = entity.GetDateTimeOffset("CreatedDate")?.DateTime ?? DateTime.MinValue
    };

    // Manually reassemble chunked strings — every large text property requires this
    var parts = new List<string>();
    if (entity.TryGetValue("Notes", out var notesVal))
        parts.Add(notesVal?.ToString() ?? "");
    int chunk = 1;
    while (entity.TryGetValue($"Notes_pt_{chunk}", out var part))
    {
        parts.Add(part?.ToString() ?? "");
        chunk++;
    }
    customer.Notes = string.Join("", parts);

    results.Add(customer);
}

The same operation with ASCDataAccess:

var results = await new List<Customer>().LoadAsync(
    x => x.CompanyId == "COMP-001" && x.IsActive == true
);

Every project that uses the raw SDK writes some version of the first block — for every entity type, for every query, for every write operation. ASCDataAccess writes it once, correctly, and gives every team that uses it back the time they would have spent on infrastructure instead of product.


Fact 2: Azure Table Storage scales to enterprise workloads at a fraction of the cost of every alternative

The technical capability gap between Azure Table Storage and platforms like Cosmos DB or MongoDB is real and documented in section 5 of this guide. But the cost gap is equally real, and for the majority of application workloads the technical constraints never surface while the cost savings compound every single month.

Real-world cost comparison — 500 GB stored, 50 million read operations/month:

Platform Approximate monthly cost ORM / Framework available
Azure Table Storage + ASCDataAccess ~$25–40 ASCDataAccess (this library)
Azure Cosmos DB (NoSQL API) ~$300–600 EF Core Cosmos provider
MongoDB Atlas (M30 cluster) ~$200–400 MongoDB.Driver with LINQ
Azure SQL Database (GP, 4 vCores) ~$370–500 Entity Framework Core / Dapper
Marten on Azure PostgreSQL (GP Gen5_4) ~$250–400 Marten (excellent)
Amazon DynamoDB (on-demand) ~$150–300 AWS SDK (no dedicated ORM)

Azure Table Storage is not marginally cheaper. It is 6 to 15 times cheaper than every named alternative for equivalent data volumes and request rates. For a startup, that gap is the difference between a sustainable infrastructure budget and one that threatens the business. For an enterprise, it is a six-figure annual saving on data tier costs that can be redirected to product development.

The platforms with richer query capabilities cost more precisely because of those capabilities. Teams that need full-text search, complex aggregation pipelines, or arbitrary secondary indexes on large datasets should evaluate Cosmos DB or MongoDB — and section 5 of this guide is honest about exactly where those needs arise. But the data shows that most application workloads — user management, order history, session state, audit logs, telemetry, configuration, queue management, file metadata — are dominated by key-based lookups and partition scans. These are the access patterns Azure Table Storage executes at scale with sub-15ms latency and at a cost no competitor comes close to matching.

The throughput ceiling is not a constraint for most applications. Azure Table Storage processes approximately 20,000 transactions per second per storage account. An application supporting 10,000 simultaneous active users — each generating an average of one read/write per second — produces roughly 10,000 TPS. That is well within the single-account ceiling, and storage account sharding is available for workloads that exceed it. The platforms with richer query languages are not faster at key lookups — they are more expensive because they do more things. If your workload does not need those things, you are paying for capability you are not using.

Schema migrations do not exist. Azure Table Storage is schemaless. Adding a new property to an entity requires changing one C# class. There are no ALTER TABLE scripts, no migration files, no downtime windows, no rollback procedures. For teams that ship frequently, this is not a minor convenience — it is a meaningful reduction in deployment complexity and risk.


Fact 3: The workloads that define most applications map perfectly to Azure Table Storage's strengths

The capabilities Azure Table Storage lacks — server-side joins, arbitrary secondary indexes, aggregation pipelines, full-text search — are real limitations. But examine the actual data access patterns that constitute the majority of code in most business applications:

Access pattern Azure Table Storage capability
"Give me this user by their ID" ✅ O(1) RowKey lookup — optimal
"Give me all orders for this customer" ✅ PartitionKey scan — fast and efficient
"Save this record" ✅ Single-entity upsert — fast
"Save these 500 records" ✅ Auto-batched — handled by the library
"Give me the next page of results" ✅ Continuation token pagination — built-in
"Process this work queue and resume after a crash" QueueData<T> + StateList<T> — built-in
"Store this session between requests" Session class — built-in
"Log this error" ErrorLogData — built-in
"Store this uploaded file" AzureBlobs — built-in
"Find all active customers by company" ✅ Lambda query — server-side OData
"Find customers whose name contains 'Smith'" ⚠️ Client-side filter — use a normalized key field
"Count all orders this month" ⚠️ Client-side aggregation — pre-compute in summary entity
"Full-text search across all documents" ❌ Not supported — use Azure Cognitive Search

The honest picture is that the first eleven rows describe the overwhelming majority of operations in a typical business application. The last two rows describe capabilities that belong in supplemental infrastructure regardless of which primary data store you use — even Cosmos DB teams typically add Azure Cognitive Search for full-text requirements.

ASCDataAccess exists to make the first eleven rows — the ones that define your application's core data layer — as clean, type-safe, and low-friction as Entity Framework Core makes SQL. On a platform that costs a fraction of any alternative. With zero schema migration overhead. On infrastructure that requires no servers to manage, no connection pools to tune, and no query plans to analyze.


The bottom line

If your application is greenfield and your team is evaluating data platforms, the question to ask is: which of my access patterns actually require capabilities that Azure Table Storage cannot provide? If the honest answer is "none" or "only in a small supplemental component," Azure Table Storage with ASCDataAccess is the correct choice for your primary data tier and delivers savings that compound every month for the life of the product.

If your application is already on Azure Table Storage and you are using the raw SDK, ASCDataAccess eliminates the infrastructure boilerplate immediately. There is no migration — your entities, your tables, and your data are unchanged. You swap the raw SDK calls for the library's API and get the same results with a fraction of the code.

In either case, ASCDataAccess is not competing with Entity Framework Core on Azure Table Storage. Entity Framework Core on Azure Table Storage does not exist. ASCDataAccess is the only framework available for this platform, and it was built by a team — Answer Sales Calls Inc. — that runs it in production across multiple enterprise applications.


3. Installation and One-Time Configuration

Install via NuGet

dotnet add package ASCDataAccess

Or via Package Manager Console:

Install-Package ASCDataAccess

Runtime compatibility: Ships as net9.0. Builds and runs on .NET 9 and .NET 10 SDKs. .NET 6, 7, and 8 projects can install the package and receive the net9.0 target automatically via NuGet TFM fallback.

appsettings.json

{
  "Azure": {
    "TableStorageName": "your-storage-account-name",
    "TableStorageKey":  "your-storage-account-key"
  }
}

Program.cs — configure once, use everywhere

using ASCTableStorage;

// Called once at startup — all DataAccess<T>, AzureBlobs, Session,
// and QueueData<T> instances resolve credentials automatically from this point.
TableStorage.Configure(
    builder.Configuration["Azure:TableStorageName"]!,
    builder.Configuration["Azure:TableStorageKey"]!
);

After this single call, every DAL class can be instantiated without passing credentials:

var da      = new DataAccess<Customer>();
var blobs   = new AzureBlobs("documents");
var session = new Session(sessionId);
var queue   = await QueueData<Order>.GetQueueAsync(queueId);

Backward compatibility: All explicit-credential constructors (new DataAccess<T>(accountName, accountKey)) still work unchanged. TableStorage.Configure() is additive, not a breaking change.

TableStorage static class

if (TableStorage.IsConfigured)
{
    var da = new DataAccess<MyEntity>();
}

string name = TableStorage.AccountName;
string key  = TableStorage.AccountKey;

4. The Data Model — RowKey, PartitionKey, and Why They Matter

This is the most important conceptual section in this guide. AI assistants and developers new to Azure Table Storage must understand this before writing any entity classes.

Azure Table Storage is a key-value store with a two-part composite key. Every row is uniquely identified by the combination of PartitionKey and RowKey. Understanding the semantic role of each is critical to designing a schema that performs well.

RowKey — the Primary Key

RowKey is the unique identifier for a row within its partition. Think of it as the primary key in a traditional relational database table. It must be unique within its PartitionKey group.

  • Use a GUID, a natural business identifier (order number, email address, product SKU), or any unique string
  • Must not contain: /, \, #, ?, or control characters
  • Maximum length: 1 KB
  • Azure uses RowKey for O(log n) point lookups — fetching by RowKey is the fastest possible query

PartitionKey — the Foreign Key / Grouping Key

PartitionKey is the grouping key that defines which logical partition a row belongs to. Think of it as a foreign key relationship — all rows with the same PartitionKey belong to the same logical group.

  • Azure physically co-locates rows with the same PartitionKey on the same storage node
  • Querying by PartitionKey alone is a fast, server-efficient operation
  • Querying across multiple PartitionKey values requires a full table scan (client-side)
  • Use PartitionKey to represent the parent entity: a customer ID, a company ID, a date bucket, a user ID
  • Maximum length: 1 KB

Practical examples of PartitionKey / RowKey design

// Orders for a customer
PartitionKey = "CUST-12345"       // Customer ID (foreign key relationship)
RowKey       = "ORD-98765"        // Order ID (primary key)

// Log entries by date
PartitionKey = "2025-04"          // Year-Month bucket (groups all April 2025 logs)
RowKey       = Guid.NewGuid()     // Unique log entry ID

// User profile
PartitionKey = "users"            // All users in one partition (small dataset only)
RowKey       = "user@example.com" // Email as natural unique key

// Songs by artist
PartitionKey = "artist-456"       // Artist ID
RowKey       = "song-789"         // Song ID

Why this matters for DataAccess<T>

The library uses PartitionKey and RowKey to route all queries. The most efficient query shapes are:

  1. GetRowObjectAsync(rowKey) — O(1), the fastest possible lookup
  2. GetCollectionAsync(partitionKey) — Fast, returns all rows for a partition
  3. GetCollectionAsync(x => x.CustomerId == "CUST-123") — Server-side OData if mapped to PartitionKey; otherwise hybrid

Design your PartitionKey to match your most common query pattern. The most common access pattern should be a PartitionKey scan, not a full table scan.


5. Azure Table Storage Hard Limits — What Every Developer and AI Must Know

AI assistants generating entity classes and schema designs must read this section carefully. Violating these limits causes runtime failures, silent data truncation, or severe performance degradation. Azure Table Storage is a capable but constrained platform — designing within its limits is non-negotiable.

Entity (row) size limit — 1 MB maximum

This is the single most important limit in Azure Table Storage. Every entity — the combined total of all its properties, keys, and system fields — must not exceed 1 megabyte. This is not a soft limit. Azure will reject the write with an error if an entity exceeds 1 MB.

This limit has significant implications for schema design:

  • An entity with 10 properties averaging 100 KB each fails at write time
  • An entity that stores a large JSON blob, several chunked strings, and many scalar fields can approach this limit without the developer realizing it
  • The library's automatic string chunking handles the 32 KB per-property limit transparently, but it does not protect you from the 1 MB entity limit — chunking just means a large string becomes many properties, all of which still count toward the total entity size

Design guidance: Keep entities lean. If a single row is approaching hundreds of kilobytes, that is a signal that some data should be moved to Azure Blob Storage or split into related child entities.

Complete hard limits table

Limit Value Notes
Entity (row) size 1 MB Hard limit — writes fail above this
Property count per entity 255 Includes 4 system properties (PartitionKey, RowKey, Timestamp, ETag) — effective max is 251 custom properties
String property size 32 KB practical / 64 KB theoretical Library auto-chunks at 32 KB; each chunk counts as a separate property toward the 255 limit
PartitionKey length 1 KB
RowKey length 1 KB
Table name 3–63 characters Alphanumeric only, must start with a letter
Batch transaction size 100 entities, all same PartitionKey Total batch payload must also be ≤ 4 MB
Query result page 1,000 entities Azure never returns more than 1,000 per page regardless of page size requested
Account throughput ~20,000 transactions/second Per storage account
Forbidden key characters / \ # ? and control chars In both PartitionKey and RowKey

How the string chunking limit compounds

When a string property exceeds 32 KB, the library splits it into chunks:

MyText         → first 32 KB     (1 property slot)
MyText_pt_1    → next 32 KB      (1 property slot)
MyText_pt_2    → remainder       (1 property slot)

A single 96 KB string consumes 3 of your 255 property slots and 96 KB of your 1 MB entity budget. An entity with five large text fields could consume 15 property slots before any scalar fields are counted. Plan accordingly — and move large content to Azure Blob Storage where it belongs.

No secondary indexes — only PartitionKey and RowKey are indexed

In Azure Table Storage, only PartitionKey and RowKey receive native index treatment. Every other property filter is executed as either an OData filter during a table scan or a client-side filter applied in memory after Azure returns results.

This means:

  • You cannot efficiently query by email address unless email is the RowKey or PartitionKey
  • You cannot efficiently query by date unless date is encoded into the key
  • You cannot efficiently query by any computed or secondary field without fetching and filtering in memory

The design implication: If you need to query by multiple access patterns, maintain multiple entity types with different key designs that store the same logical data indexed differently. This is the standard Azure Table Storage pattern for secondary access paths and is documented with full examples in section 6.

No server-side joins

Azure Table Storage has no concept of a JOIN. Every cross-entity relationship must be resolved in application code with multiple queries.

// ❌ Not possible in Azure Table Storage
// SELECT c.*, o.* FROM Customers c JOIN Orders o ON c.CustomerId = o.CustomerId

// ✅ Two separate queries, assembled in code
var customer = await customerDa.GetRowObjectAsync(customerId);
var orders   = await orderDa.GetCollectionAsync(customerId);  // PartitionKey = customerId

No server-side aggregates

Azure Table Storage has no COUNT, SUM, AVG, MIN, MAX, or GROUP BY. All aggregation must be performed client-side after fetching records. For heavy reporting, supplement with Azure Synapse Analytics or pre-compute aggregates into summary entities at write time.

No cross-partition transactions

Batch operations are atomic only within a single PartitionKey. You cannot perform an atomic batch that spans two different PartitionKey values.

No server-side string search (LIKE / CONTAINS)

OData v2 does not support LIKE, CONTAINS, or STARTSWITH as server-side indexed operations. When you write a lambda using string methods, the library fetches the partition (or table) and evaluates client-side. For large tables, store a normalized search key at write time and filter by equality.

// ⚠️ Client-side full scan
await da.GetCollectionAsync(x => x.ProductName.Contains("widget"));

// ✅ Normalize at write time, query by equality
await da.GetCollectionAsync(x => x.SearchKey == "widget");

Comparison to other NoSQL platforms

Capability Azure Table Storage Amazon DynamoDB Azure Cosmos DB (NoSQL) MongoDB
Max row / document size 1 MB 400 KB 2 MB 16 MB
Secondary indexes ❌ None ✅ GSI / LSI ✅ Composite indexes ✅ Any field
Server-side aggregates ❌ None ❌ Limited ✅ Full pipeline ✅ Full pipeline
Cross-partition transactions ❌ No ✅ Transactions API ✅ Multi-document ACID ✅ Multi-document ACID
Query language OData v2 (minimal) PartiQL / Conditions SQL-like MQL / Aggregation
String search server-side ❌ No contains() CONTAINS() $regex
ORM / Framework ASCDataAccess None EF Core provider MongoDB.Driver
Approximate cost (500 GB, 50M reads/mo) ~$25–40 ~$150–300 ~$300–600 ~$200–400

The cost column is the decisive column for any team that has honestly evaluated their query requirements and confirmed that Azure Table Storage's access patterns meet their needs. The technical constraints are real. The 6–15x cost advantage over every alternative is equally real.


This section is critical for AI-assisted code generation. The single most common schema design mistake when using ASCDataAccess — whether by developers or by AI tools — is choosing incorrectly between JSON-serialized fields and proper related entities. Getting this decision wrong leads to queries that cannot filter, data that cannot be searched, and entity sizes that approach or exceed the 1 MB limit.

The core rule

Use a JSON field when:

  • The child data is small, bounded, and always travels with the parent
  • The child data is never filtered, sorted, or searched independently
  • The child data does not need to be accessed without its parent
  • The data is configuration or display metadata — not a queryable entity in its own right

Use a related entity (separate table row) when:

  • The child data needs to be queried, filtered, or sorted on its own
  • You need to count, aggregate, or report on the child data
  • The child data can grow to an unbounded number of items
  • Any lambda expression will ever need to filter on the child data
  • The child data is logically independent and has meaning outside the parent context

Why JSON fields break lambda queries

When a property is stored as a JSON string, the library has no way to filter on its internal structure. The lambda expression visitor only understands top-level entity properties. To filter by anything inside a JSON field, you must fetch all candidate rows and deserialize each one — a full table scan with expensive per-row deserialization.

// ❌ Broken — Tags is a JSON string; lambda cannot filter inside it
public class Product : TableEntityBase, ITableExtra
{
    public string? TagsJson { get; set; }  // Stored as "[\"electronics\",\"wireless\"]"
}

// Fetches ALL products, filters client-side — semantically unreliable
await da.GetCollectionAsync(x => x.TagsJson.Contains("electronics"));  // Wrong and slow
// ✅ Product tags as a proper related entity — fully queryable
public class ProductTag : TableEntityBase, ITableExtra
{
    public string ProductId   // PartitionKey — groups all tags for a product
    {
        get => PartitionKey ?? string.Empty;
        set => PartitionKey = value;
    }

    public string TagName     // RowKey — unique tag within the product
    {
        get => RowKey ?? string.Empty;
        set => RowKey = value;
    }

    public string TableReference => "ProductTags";
    public string GetIDValue()   => TagName;
}

var tagDa = new DataAccess<ProductTag>();

// All tags for a specific product — fast PartitionKey scan
var tags = await tagDa.GetCollectionAsync(productId);

When JSON fields ARE the right choice

public class UserProfile : TableEntityBase, ITableExtra
{
    public string UserId   { get => RowKey!;       set => RowKey = value; }
    public string Region   { get => PartitionKey!; set => PartitionKey = value; }

    // ✅ Small, bounded, never queried independently — JSON is correct
    public DisplayPreferences? Preferences  { get; set; }
    public ContactInfo?        ContactInfo  { get; set; }

    // ❌ Wrong — subscription tier affects queries; use a related entity
    // public List<string> SubscriptionFeatures { get; set; }

    public string TableReference => "UserProfiles";
    public string GetIDValue()   => UserId;
}

public class DisplayPreferences
{
    public string Theme        { get; set; } = "light";
    public string Language     { get; set; } = "en";
    public bool   ShowTutorials { get; set; } = true;
    public int    ItemsPerPage  { get; set; } = 25;
}

Decision checklist

Question If YES → If NO →
Will a lambda ever filter on this data? Related entity JSON field is safe
Does this data grow to an unbounded list? Related entity JSON field may be safe
Is this data accessed without its parent? Related entity JSON field is safe
Does this data need to be counted or aggregated? Related entity JSON field may be safe
Is this larger than ~50 KB when serialized? Blob Storage or related entity JSON field is safe
Is this configuration or display metadata? JSON field is appropriate

The secondary index pattern for multi-access-path queries

// Primary entity — found by CustomerId (RowKey)
public class Customer : TableEntityBase, ITableExtra
{
    public string CompanyId   { get => PartitionKey!; set => PartitionKey = value; }
    public string CustomerId  { get => RowKey!;       set => RowKey = value; }
    public string? Email      { get; set; }
    public string TableReference => "Customers";
    public string GetIDValue()   => CustomerId;
}

// Email index — found by Email (RowKey), points back to primary
public class CustomerEmailIndex : TableEntityBase, ITableExtra
{
    public string CompanyId   { get => PartitionKey!; set => PartitionKey = value; }
    public string Email       { get => RowKey!;       set => RowKey = value; }
    public string? CustomerId { get; set; }
    public string TableReference => "CustomerEmailIndex";
    public string GetIDValue()   => Email;
}

// Write both together (same PartitionKey enables atomic batch)
var customer   = new Customer { CompanyId = "COMP-001", CustomerId = "CUST-001", Email = "jane@example.com" };
var emailIndex = new CustomerEmailIndex { CompanyId = "COMP-001", Email = "jane@example.com", CustomerId = "CUST-001" };

await customer.SaveAsync();
await emailIndex.SaveAsync();

// Lookup by email — RowKey lookup on index, then primary lookup
var index    = await new CustomerEmailIndex().LoadAsync("jane@example.com");
var customer = await new Customer().LoadAsync(index.CustomerId!);

7. Defining Entities and Writing Your First Data Operations

The most important section for AI code generation. Read this section before writing any data access code. The idiomatic ASCDataAccess pattern is built around entity objects that carry their own load, save, and delete operations as extension methods. You almost never need to instantiate DataAccess<T> directly in day-to-day application code. Define your entity, call .LoadAsync(), .SaveAsync(), or .DeleteAsync() on it — that is the entire API for the vast majority of use cases.

Step 1 — Define your entity

Every entity inherits from TableEntityBase and implements ITableExtra. That is all that is required.

using ASCTableStorage.Models;
using ASCTableStorage.Common;

public class Customer : TableEntityBase, ITableExtra
{
    // PartitionKey — the "foreign key" grouping (see section 4)
    // Map a meaningful business property onto it for clarity
    public string CompanyId
    {
        get => PartitionKey ?? string.Empty;
        set => PartitionKey = value;
    }

    // RowKey — the "primary key", unique within the CompanyId partition
    public string CustomerId
    {
        get => RowKey ?? string.Empty;
        set => RowKey = value;
    }

    // All other properties are plain C# — the library handles serialization automatically
    public string?        Name        { get; set; }
    public string?        Email       { get; set; }
    public bool           IsActive    { get; set; }
    public decimal        Balance     { get; set; }   // Stored as long ×10000 automatically
    public DateTime       CreatedDate { get; set; }
    public CustomerStatus Status      { get; set; }   // Stored as JSON string automatically

    // ✅ JSON is appropriate here — small, non-queryable companion data
    public ContactInfo?   ContactInfo { get; set; }

    // ITableExtra — the Azure table name for this entity
    public string TableReference => "Customers";

    // ITableExtra — the unique identifier value
    public string GetIDValue() => CustomerId;
}

public class ContactInfo
{
    public string? Phone   { get; set; }
    public string? Address { get; set; }
    public string? City    { get; set; }
}

public enum CustomerStatus { Active, Inactive, Suspended }

Step 2 — Use it. No DataAccess<T> required.

Once your entity is defined, every data operation is an extension method on the entity itself or on a List<T> of it. This is the primary pattern for all everyday data access in ASCDataAccess. You call operations directly on your objects — exactly the way you think about your data.

using ASCTableStorage.Common;  // brings in the extension methods

Load a single entity by its RowKey (primary key):

var customer = await new Customer().LoadAsync("CUST-042");

Load a single entity by lambda expression:

var customer = await new Customer().LoadAsync(x => x.Email == "jane@example.com");

Load a collection by lambda expression:

var customers = await new List<Customer>().LoadAsync(x => x.CompanyId == "COMP-001" && x.IsActive == true);

Modify and save:

customer.Balance  = 2500.00m;
customer.IsActive = true;
await customer.SaveAsync();

Create and save a new entity:

var newCustomer = new Customer
{
    CompanyId  = "COMP-001",
    CustomerId = Guid.NewGuid().ToString(),
    Name       = "Jane Smith",
    Email      = "jane@example.com",
    IsActive   = true,
    Balance    = 0m,
    Status     = CustomerStatus.Active
};

await newCustomer.SaveAsync();

Delete:

await customer.DeleteAsync();

Save or delete a collection:

await customers.SaveAsync();
await customers.DeleteAsync();

That is it. For the overwhelming majority of application code — service classes, controllers, background workers — this is all you ever write. No DataAccess<T>, no constructor arguments, no credential passing. Define your entity, call extension methods on it.

The complete lifecycle in one place

// Load → modify → save
var order = await new Order().LoadAsync(x => x.OrderId == orderId);
order.Status = OrderStatus.Shipped;
order.ShippedDate = DateTime.UtcNow;
await order.SaveAsync();

// Load a collection → filter further in memory → delete
var staleOrders = await new List<Order>().LoadAsync(
    x => x.CompanyId == companyId && x.Status == OrderStatus.Cancelled
);
await staleOrders.DeleteAsync();

// Load collection → update all → save all
var pendingOrders = await new List<Order>().LoadAsync(x => x.Status == OrderStatus.Pending);
pendingOrders.ForEach(o => o.Priority = Priority.High);
await pendingOrders.SaveAsync();

ITableExtra interface

public interface ITableExtra
{
    string TableReference { get; }  // The Azure table name for this entity type
    string GetIDValue();            // The primary identifier value (maps to RowKey)
}

Automatic type handling — nothing to configure

C# Type Storage reality What the library does
decimal Not natively supported Multiplied by 10,000, stored as long; reversed on read
string > 32 KB 32 KB per-property limit Auto-chunked into sequential fields; reassembled on read
enum Not natively supported JSON-serialized as string name
Complex objects (List<T>, custom classes) Not natively supported JSON-serialized; deserialized on read
bool Multiple formats in storage Parses true/false, 1/0, yes/no
DateTime / DateTimeOffset Multiple formats in storage Intelligent multi-format parsing

Entity size discipline

  • Keep scalar properties scalar: string, int, bool, decimal, DateTime, Guid
  • Use JSON only for small, bounded, non-queryable companion data (see section 6)
  • Move large content — HTML, source code, binary files — to Blob Storage; store the URL
  • 255 properties maximum per entity; chunked strings consume multiple slots
  • Call entity.GetDiagnostics() in development to inspect sizes before production

PartitionKey change tracking

If you change the PartitionKey of an existing entity and save it, the library automatically deletes the old row and inserts under the new partition — transparent to the caller.

var customer = await new Customer().LoadAsync("CUST-001");
customer.CompanyId = "NEW-COMPANY";  // PartitionKey change
await customer.SaveAsync();          // Delete-from-old + insert-to-new handled automatically

8. Lambda Expressions — What Works Server-Side and What Does Not

AI code generation rule for lambda queries: Always write the lambda on the entity or collection directly using extension methods. Use new Customer().LoadAsync(lambda) or new List<Customer>().LoadAsync(lambda) — not new DataAccess<Customer>().GetCollectionAsync(lambda). The result is identical; the extension method form is shorter, cleaner, and requires no boilerplate.

ASCDataAccess translates C# lambda expressions into OData filter strings for server-side execution wherever possible. When a lambda contains operations OData v2 cannot evaluate, the library splits automatically: the server-evaluable part runs as an OData query, the remainder is evaluated client-side on the results.

Supported server-side operations — use freely

// Equality and comparison
var active   = await new List<Customer>().LoadAsync(x => x.Status == CustomerStatus.Active);
var wealthy  = await new List<Customer>().LoadAsync(x => x.Balance > 1000m);
var recent   = await new List<Customer>().LoadAsync(x => x.CreatedDate >= new DateTime(2024, 1, 1));

// Logical AND / OR
var vip      = await new List<Customer>().LoadAsync(x => x.IsActive == true && x.Balance > 500m);
var flagged  = await new List<Customer>().LoadAsync(
    x => x.Status == CustomerStatus.Active || x.Status == CustomerStatus.Suspended);

// Null / empty checks
var noEmail  = await new List<Customer>().LoadAsync(x => string.IsNullOrEmpty(x.Email));

// Boolean — explicit comparison is always correct
var active2  = await new List<Customer>().LoadAsync(x => x.IsActive == true);
var active3  = await new List<Customer>().LoadAsync(x => x.IsActive);  // Auto-expanded internally

// Single entity by lambda
var customer = await new Customer().LoadAsync(x => x.Email == "jane@example.com");

Operations that force a full table fetch — avoid on large tables

// ⚠️ String methods are not translatable to OData — fetches all rows, filters in memory
var found = await new List<Customer>().LoadAsync(x => x.Name.Contains("Smith"));
var found = await new List<Customer>().LoadAsync(x => x.Email.StartsWith("jane"));
var found = await new List<Customer>().LoadAsync(x => x.Name.ToLower() == "smith");

// ⚠️ Null checks on string properties force full scan
var noEmail = await new List<Customer>().LoadAsync(x => x.Email == null);

// ❌ Properties inside JSON-serialized complex type fields — never use in lambdas
var boston = await new List<Customer>().LoadAsync(x => x.ContactInfo.City == "Boston");

Query performance summary

Pattern Performance Guidance
.LoadAsync("rowKey") Excellent — O(1) Always prefer when the RowKey is known
.LoadAsync(x => x.CompanyId == id) (maps to PartitionKey) Excellent Primary query pattern
Lambda with ==, !=, >, <, >=, <= Good — server-side OData Use freely
Lambda with string.IsNullOrEmpty() Good — server-side Safe to use
Lambda with Contains(), StartsWith() Poor on large tables Store a normalized key field instead
Lambda on JSON field internals Never works correctly Use a related entity
No predicate (load all) Worst — full table scan Only for small/admin tables

9. When You Do Need DataAccess<T> Directly

DataAccess<T> is the engine that powers all extension methods. For everyday single-entity and collection operations you never need to touch it — the extension method pattern handles everything. You reach for DataAccess<T> directly when you need capabilities the extension methods do not expose:

  • Batch operations with progress tracking across thousands of records
  • Cursor-based pagination for large result sets
  • Raw OData filter strings for edge-case queries
  • Dynamic query builder (DBQueryItem) for runtime-constructed queries
  • Multi-account or migration scenarios targeting a different storage account
  • Table name or PartitionKey property overrides via TableOptions
// Only instantiate DataAccess<T> when you need one of the above capabilities
var da = new DataAccess<Customer>();                              // Default credentials
var da = new DataAccess<Customer>("accountName", "accountKey");  // Explicit credentials
var da = new DataAccess<Customer>(new TableOptions              // Table/key override
{
    TableName                = "CustomersArchive",
    PartitionKeyPropertyName = "RegionId"
});

Do not instantiate DataAccess<T> for simple load/save/delete operations. Use the extension methods on your entity objects. They resolve DataAccess<T> internally — you get the same result with a fraction of the code.

When DataAccess<T> is the right choice

// ✅ Batch — extension methods don't expose progress callbacks or batch results
var da     = new DataAccess<Customer>();
var result = await da.BatchUpdateListAsync(largeCustomerList, TableOperationType.InsertOrReplace, progress);
Console.WriteLine($"{result.SuccessfulItems} succeeded, {result.FailedItems} failed");

// ✅ Pagination — extension methods don't expose continuation tokens
var page = await da.GetPagedCollectionAsync(
    x => x.Status == CustomerStatus.Active, pageSize: 50, continuationToken: token);

// ✅ Raw OData — for queries that cannot be expressed as a lambda
var results = await da.GetCollectionByFilterAsync(
    "Status eq 'Active' and CreatedDate gt datetime'2024-01-01'");

// ✅ Dynamic query — runtime-constructed filter
var queryTerms = new List<DBQueryItem>
{
    new DBQueryItem { FieldName = "Status",   FieldValue = "Active", HowToCompare = ComparisonTypes.eq },
    new DBQueryItem { FieldName = "IsActive", FieldValue = "true",   HowToCompare = ComparisonTypes.eq },
};
var results = await da.GetCollectionAsync(queryTerms, QueryCombineStyle.And);

// ✅ Data migration between accounts
var source = new DataAccess<Customer>("source-account", "source-key");
var dest   = new DataAccess<Customer>("dest-account",   "dest-key");
var data   = await source.GetAllTableDataAsync();
await dest.BatchUpdateListAsync(data, TableOperationType.InsertOrReplace);

TableOperationType — used by both extension methods and DataAccess<T>

Value Behavior
InsertOrReplace Upsert — full replace if exists (default for .SaveAsync())
InsertOrMerge Upsert — merge properties if exists (preserves unset fields)
Delete Delete the entity (default for .DeleteAsync())

10. Choosing the Right Pattern — Quick Reference

AI code generation: always start here. Pick the simplest pattern that satisfies the requirement. Only escalate to DataAccess<T> when the extension methods genuinely cannot do what you need.

// ─────────────────────────────────────────────────────────────────────────────
// PATTERN 1: Single entity — load by primary key
// Use when: you know the RowKey (ID) of the record
// ─────────────────────────────────────────────────────────────────────────────
var customer = await new Customer().LoadAsync("CUST-042");


// ─────────────────────────────────────────────────────────────────────────────
// PATTERN 2: Single entity — load by lambda
// Use when: you need to find one record by a non-key field
// ─────────────────────────────────────────────────────────────────────────────
var customer = await new Customer().LoadAsync(x => x.Email == "jane@example.com");


// ─────────────────────────────────────────────────────────────────────────────
// PATTERN 3: Collection — load by lambda
// Use when: you need all matching records for a query
// ─────────────────────────────────────────────────────────────────────────────
var customers = await new List<Customer>().LoadAsync(
    x => x.CompanyId == "COMP-001" && x.IsActive == true);


// ─────────────────────────────────────────────────────────────────────────────
// PATTERN 4: Create or update
// Use when: saving any entity regardless of whether it already exists
// ─────────────────────────────────────────────────────────────────────────────
var order = new Order { CompanyId = "COMP-001", OrderId = "ORD-001", Total = 599.99m };
await order.SaveAsync();                                          // InsertOrReplace (default)
await order.SaveAsync(TableOperationType.InsertOrMerge);         // Merge into existing


// ─────────────────────────────────────────────────────────────────────────────
// PATTERN 5: Load → modify → save (the most common real-world pattern)
// Use when: updating an existing record
// ─────────────────────────────────────────────────────────────────────────────
var order = await new Order().LoadAsync("ORD-001");
order.Status    = OrderStatus.Shipped;
order.ShippedAt = DateTime.UtcNow;
await order.SaveAsync();


// ─────────────────────────────────────────────────────────────────────────────
// PATTERN 6: Delete
// Use when: removing a record
// ─────────────────────────────────────────────────────────────────────────────
await order.DeleteAsync();


// ─────────────────────────────────────────────────────────────────────────────
// PATTERN 7: Load collection → modify all → save all
// Use when: bulk update of a set of related records
// ─────────────────────────────────────────────────────────────────────────────
var orders = await new List<Order>().LoadAsync(
    x => x.CompanyId == "COMP-001" && x.Status == OrderStatus.Pending);

orders.ForEach(o => o.Priority = Priority.High);
await orders.SaveAsync();


// ─────────────────────────────────────────────────────────────────────────────
// PATTERN 8: Load collection → delete all matching
// Use when: removing a set of records
// ─────────────────────────────────────────────────────────────────────────────
var cancelled = await new List<Order>().LoadAsync(x => x.Status == OrderStatus.Cancelled);
await cancelled.DeleteAsync();


// ─────────────────────────────────────────────────────────────────────────────
// PATTERN 9: Batch — only when saving/deleting thousands of records at once
// Escalate to DataAccess<T> for progress tracking and batch result inspection
// ─────────────────────────────────────────────────────────────────────────────
var da     = new DataAccess<Order>();
var result = await da.BatchUpdateListAsync(largeOrderList, TableOperationType.InsertOrReplace, progress);


// ─────────────────────────────────────────────────────────────────────────────
// PATTERN 10: Pagination — only when driving a paged UI or API endpoint
// Escalate to DataAccess<T> for continuation token control
// ─────────────────────────────────────────────────────────────────────────────
var da   = new DataAccess<Order>();
var page = await da.GetPagedCollectionAsync(x => x.CompanyId == "COMP-001", 50, token);

11. Batch Operations

ASCDataAccess handles batch operations with automatic chunking at Azure Table Storage's strict 100-entity-per-transaction limit.

Batch constraint: All entities in a single batch transaction must share the same PartitionKey. The library handles this automatically by grouping entities by partition before submitting.

Basic batch

var result = await da.BatchUpdateListAsync(customers);
var result = await da.BatchUpdateListAsync(customers, TableOperationType.InsertOrMerge);
bool ok    = da.BatchUpdateList(customers);

With progress tracking

var progress = new Progress<BatchUpdateProgress>(p =>
    Console.WriteLine($"{p.ProcessedItems}/{p.TotalItems} ({p.PercentComplete:F1}%) " +
                      $"Success: {p.SuccessfulItems}, Failed: {p.FailedItems}")
);

var result = await da.BatchUpdateListAsync(customers, TableOperationType.InsertOrReplace, progress);

Console.WriteLine($"{result.SuccessfulItems} succeeded, {result.FailedItems} failed");

BatchUpdateResult

Property Type Description
Success bool True if all items succeeded
SuccessfulItems int Count of successful operations
FailedItems int Count of failed operations
Errors List<string> Error details for failed items

Batch delete

await da.BatchUpdateListAsync(staleRecords, TableOperationType.Delete);
await staleRecords.DeleteAsync();

12. Pagination

Azure page size ceiling: Azure Table Storage never returns more than 1,000 entities per page regardless of the pageSize value requested.

Basic pagination loop

string? continuationToken = null;

do
{
    var page = await da.GetPagedCollectionAsync(100, continuationToken);

    foreach (var customer in page.Items)
    {
        // Process each customer
    }

    continuationToken = page.ContinuationToken;

} while (!string.IsNullOrEmpty(continuationToken));

By PartitionKey

var page = await da.GetPagedCollectionAsync("COMP-001", pageSize: 50, continuationToken: null);

By lambda expression

var page = await da.GetPagedCollectionAsync(
    predicate:         x => x.Status == CustomerStatus.Active,
    pageSize:          25,
    continuationToken: previousToken
);

// page.Items             — current page items
// page.ContinuationToken — null when done
// page.HasMore           — true if additional pages exist
// page.TotalReturned     — count of items in this page

Initial data load

var initialPage = await da.GetInitialDataLoadAsync(initialLoadSize: 50);
var initialPage = await da.GetInitialDataLoadAsync(x => x.IsActive == true, initialLoadSize: 50);

13. Dynamic Entities — Schema-Free Storage

DynamicEntity is one of the most powerful and underutilized features in ASCDataAccess. Where strongly-typed entities require you to know the schema at compile time, DynamicEntity lets you define, store, and retrieve data whose shape is determined at runtime. It uses the same Azure Table Storage infrastructure as typed entities — the same PartitionKey/RowKey model, the same automatic serialization, the same 1 MB entity budget — but with a dictionary-based property model instead of a class definition.

The 1 MB entity limit applies equally to DynamicEntity. Do not use it as an escape hatch for storing large arbitrary payloads.

When DynamicEntity is the right choice

Scenario Why DynamicEntity fits
Telemetry and event tracking Each event type has different properties — you cannot pre-define them all
Multi-tenant configuration Each tenant defines their own custom fields
ETL / import pipelines You are processing external data whose schema arrives at runtime (CSV, JSON API, webhook payload)
Form builder / custom field systems Users define their own data structures in your app
Feature flags and A/B test metadata Properties added and removed without deploying new entity classes
Prototype / rapid iteration You want to persist data before you have committed to a final schema
Bridging legacy data Existing data in Table Storage whose columns do not map to a typed class
AI / ML feature stores Feature sets evolve continuously without a migration cycle

Use case 1: Telemetry and event tracking

Different event types carry completely different properties. Rather than maintaining a giant event class with 50 nullable fields, store each event as a DynamicEntity with exactly the properties it carries:

// Page view event — has URL, referrer, duration
var pageView = new DynamicEntity("TelemetryEvents",
    partitionKey: $"2025-04",           // Date bucket — efficient time-range queries
    rowKey:       Guid.NewGuid().ToString());

pageView["EventType"]   = "PageView";
pageView["UserId"]      = userId;
pageView["Url"]         = "/products/headphones";
pageView["Referrer"]    = "google.com";
pageView["DurationMs"]  = 1240;
pageView["SessionId"]   = sessionId;

var da = new DataAccess<DynamicEntity>();
await da.ManageDataAsync(pageView);

// Purchase event — completely different properties, same table
var purchase = new DynamicEntity("TelemetryEvents",
    partitionKey: "2025-04",
    rowKey:       Guid.NewGuid().ToString());

purchase["EventType"]   = "Purchase";
purchase["UserId"]      = userId;
purchase["OrderId"]     = "ORD-9871";
purchase["Amount"]      = 149.99;
purchase["Currency"]    = "USD";
purchase["ProductSku"]  = "SKU-HDPH-001";
purchase["IsFirstBuy"]  = true;

await da.ManageDataAsync(purchase);

Both events live in the same table, partitioned by month. You query them the same way:

// All events for April 2025
var aprilEvents = await da.GetCollectionAsync("2025-04");

// Filter by event type after fetch
var purchases = aprilEvents
    .Where(e => e["EventType"]?.ToString() == "Purchase")
    .ToList();

Use case 2: Multi-tenant custom configuration

Each tenant in your SaaS app defines their own configuration keys. You cannot define a typed entity because you do not know what keys each tenant will use:

// Tenant A configures their own notification rules
var tenantConfig = new DynamicEntity("TenantConfig",
    partitionKey: "TENANT-A",
    rowKey:       "NotificationSettings");

tenantConfig["EmailEnabled"]       = true;
tenantConfig["EmailAddress"]       = "alerts@tenant-a.com";
tenantConfig["SlackEnabled"]       = true;
tenantConfig["SlackWebhook"]       = "https://hooks.slack.com/...";
tenantConfig["AlertThreshold"]     = 95.5;
tenantConfig["TimeZone"]           = "America/New_York";

var da = new DataAccess<DynamicEntity>();
await da.ManageDataAsync(tenantConfig);

// Later, read back and access individual settings
var config = await da.GetRowObjectAsync("TENANT-A", ComparisonTypes.eq, "NotificationSettings");

bool emailEnabled  = bool.Parse(config["EmailEnabled"]?.ToString() ?? "false");
string? webhook    = config["SlackWebhook"]?.ToString();
double threshold   = double.Parse(config["AlertThreshold"]?.ToString() ?? "0");

Use case 3: Import pipeline — persisting external data at runtime

You receive a JSON payload from a webhook or API whose schema you do not control. Rather than deserializing into a fixed class that breaks whenever the external schema changes, map it directly to a DynamicEntity:

// Incoming Stripe webhook payload — schema varies by event type
string webhookJson = await request.Content.ReadAsStringAsync();

var entity = DynamicEntity.CreateFromJson(
    tableName:   "StripeEvents",
    json:        webhookJson,
    patternConfig: new DynamicEntity.KeyPatternConfig
    {
        PartitionKeyPatterns = new List<DynamicEntity.PatternRule>
        {
            new() { Pattern = @"type",    Priority = 10, KeyType = DynamicEntity.KeyGenerationType.DirectValue }
        },
        RowKeyPatterns = new List<DynamicEntity.PatternRule>
        {
            new() { Pattern = @"id",      Priority = 10, KeyType = DynamicEntity.KeyGenerationType.DirectValue }
        }
    }
);

var da = new DataAccess<DynamicEntity>();
await da.ManageDataAsync(entity);

// The entire webhook payload is now persisted with its native structure
// No class needed, no schema migration when Stripe adds fields

Use case 4: Bridging any C# object without a typed entity class

You have an existing C# object — perhaps from a third-party library or a DTO from an external API — and you want to persist it to Table Storage without creating a TableEntityBase subclass for it:

// Any C# object — no TableEntityBase required
public class ExternalProductDto
{
    public string ProductId    { get; set; } = "";
    public string CategoryId   { get; set; } = "";
    public string Name         { get; set; } = "";
    public decimal Price       { get; set; }
    public bool IsAvailable    { get; set; }
    public List<string> Tags   { get; set; } = new();
}

var product = new ExternalProductDto
{
    ProductId  = "prod-001",
    CategoryId = "electronics",
    Name       = "Wireless Headphones",
    Price      = 149.99m,
    IsAvailable = true,
    Tags       = new List<string> { "audio", "wireless", "bluetooth" }
};

// Convert any object to DynamicEntity — reflection maps all public properties
var entity = DynamicEntity.CreateFromObject(
    product,
    tableName:               "Products",
    partitionKeyPropertyName: "CategoryId"  // Which property becomes PartitionKey
);

var da = new DataAccess<DynamicEntity>();
await da.ManageDataAsync(entity);

Use case 5: Feature flag and A/B test store

Feature flags evolve constantly — flags are added, removed, and modified without a deployment cycle. DynamicEntity stores them without requiring entity class changes:

var da = new DataAccess<DynamicEntity>();

// Write a feature flag — any shape, any properties
var flag = new DynamicEntity("FeatureFlags",
    partitionKey: "v2-rollout",
    rowKey:       "new-checkout-flow");

flag["Enabled"]          = true;
flag["RolloutPercent"]   = 25;
flag["TargetRegions"]    = "US,CA,GB";
flag["ExcludedUserIds"]  = "user-123,user-456";
flag["ExpiresAt"]        = DateTime.UtcNow.AddDays(30).ToString("O");
flag["Notes"]            = "Phase 1 rollout — watch conversion rate";

await da.ManageDataAsync(flag);

// Read back and use
var allFlags = await da.GetCollectionAsync("v2-rollout");

var checkoutFlag = allFlags.FirstOrDefault(f => f.RowKey == "new-checkout-flow");
bool isEnabled   = bool.Parse(checkoutFlag?["Enabled"]?.ToString() ?? "false");
int rollout      = int.Parse(checkoutFlag?["RolloutPercent"]?.ToString() ?? "0");

Explicit key construction

When you know exactly what your PartitionKey and RowKey should be, use the explicit constructor:

var entity = new DynamicEntity(
    tableName:    "AuditLog",
    partitionKey: $"{userId}",          // All audit entries for a user in one partition
    rowKey:       $"{DateTime.UtcNow:O}_{Guid.NewGuid()}"  // Timestamp + GUID for uniqueness
);

entity["Action"]     = "PasswordChanged";
entity["IpAddress"]  = ipAddress;
entity["UserAgent"]  = userAgent;
entity["Success"]    = true;

var da = new DataAccess<DynamicEntity>();
await da.ManageDataAsync(entity);

Pattern-based automatic key detection

When you have a dictionary of properties and want the library to figure out which fields should be PartitionKey and RowKey based on naming patterns:

var config = new DynamicEntity.KeyPatternConfig
{
    PartitionKeyPatterns = new List<DynamicEntity.PatternRule>
    {
        // Any field whose name matches ".*company.*id.*" becomes the PartitionKey
        new() { Pattern = @".*company.*id.*", Priority = 10,
                KeyType = DynamicEntity.KeyGenerationType.DirectValue },

        // Fields matching ".*date.*" become a date-bucket PartitionKey (YYYY-MM)
        new() { Pattern = @".*created.*date.*", Priority = 5,
                KeyType = DynamicEntity.KeyGenerationType.DateBased }
    },
    RowKeyPatterns = new List<DynamicEntity.PatternRule>
    {
        new() { Pattern = @".*order.*id.*", Priority = 10,
                KeyType = DynamicEntity.KeyGenerationType.DirectValue },

        // If no ID field found, generate a reverse timestamp (newest-first ordering)
        new() { Pattern = @".*",            Priority = 1,
                KeyType = DynamicEntity.KeyGenerationType.ReverseTimestamp }
    },
    FuzzyMatchThreshold = 0.8  // 1.0 = exact match only; 0.0 = everything matches
};

var entity = new DynamicEntity("Orders", new Dictionary<string, object>
{
    ["CompanyId"]    = "COMP-001",   // → PartitionKey (matches company.*id)
    ["OrderId"]      = "ORD-999",   // → RowKey (matches order.*id)
    ["Amount"]       = 499.00,
    ["Status"]       = "Pending",
    ["CreatedDate"]  = DateTime.UtcNow
}, config);

KeyGenerationType values

Value Description Best for
DirectValue Use field value as-is IDs, codes, natural keys
DateBased Convert to YYYY-MM bucket Time-series data, log archives
Sequential Generate sequential key Ordered processing queues
ReverseTimestamp Newest-first timestamp Activity feeds, notifications
Composite Combine multiple fields Compound natural keys
Generated Generate a new GUID When no natural key exists

Reading back and accessing properties

var da = new DataAccess<DynamicEntity>();

// Fetch a single entity
var entity = await da.GetRowObjectAsync("ORD-999");

// Access properties by key — returns object?
string? status = entity["Status"]?.ToString();
double  amount = double.Parse(entity["Amount"]?.ToString() ?? "0");

// Diagnostics — see what the entity contains
Console.WriteLine(entity.ToString());      // Human-readable property dump
Console.WriteLine(entity.GetDiagnostics()); // Property counts and sizes

Lambda expressions with DynamicEntity

Lambda expressions work with DataAccess<DynamicEntity> the same way they do with typed entities — with one important constraint to understand.

What is queryable server-side: DynamicEntity exposes PartitionKey, RowKey, and Timestamp as real C# properties. Lambda predicates on those three properties are translated to OData and execute server-side efficiently.

What is not queryable server-side: The dictionary-based properties — entity["Status"], entity["Amount"], etc. — are not real C# properties. They cannot be translated into an OData filter. Predicates that reference them are evaluated client-side after Azure returns the results.

This means the right query strategy for DynamicEntity is: use PartitionKey to scope the query server-side, then filter by dynamic properties in memory.

var da = new DataAccess<DynamicEntity>();

// ✅ Server-side — PartitionKey and RowKey are real properties, fully translated to OData
var aprilEvents  = await da.GetCollectionAsync("2025-04");          // All April 2025 events
var specificFlag = await da.GetRowObjectAsync("new-checkout-flow");  // Single entity by RowKey

// ✅ Server-side lambda on PartitionKey
var tenantData = await da.GetCollectionAsync(
    x => x.PartitionKey == "TENANT-A"
);

// ✅ Server-side lambda on Timestamp — useful for time-range queries
var recentEntries = await da.GetCollectionAsync(
    x => x.Timestamp >= DateTimeOffset.UtcNow.AddHours(-1)
);

// ✅ Server-side — PartitionKey scope + client-side filter on dynamic property
// Azure executes the PartitionKey filter; your code filters the results in memory
var aprilPurchases = (await da.GetCollectionAsync("2025-04"))
    .Where(e => e["EventType"]?.ToString() == "Purchase")
    .ToList();

// ✅ Pattern: PartitionKey scopes efficiently, in-memory filter refines
var activeFlags = (await da.GetCollectionAsync("v2-rollout"))
    .Where(f => bool.TryParse(f["Enabled"]?.ToString(), out var enabled) && enabled)
    .ToList();

// ⚠️ This executes client-side — dynamic properties cannot reach OData
// Fine for small partitions, expensive for large ones
var da2 = new DataAccess<DynamicEntity>();
var allPurchases = await da2.GetCollectionAsync(
    x => x["EventType"]!.ToString() == "Purchase"  // ⚠️ Not an OData-translatable expression
);

The practical query pattern for DynamicEntity

Because dynamic properties cannot drive a server-side filter, the most efficient query pattern is always:

  1. Scope with PartitionKey — let Azure handle the coarse filter
  2. Refine with LINQ — filter by dynamic properties in memory on the scoped result

This is why good PartitionKey design matters even more for DynamicEntity than for typed entities. A well-chosen partition (user ID, date bucket, tenant ID, event category) keeps the in-memory filter set small. A poor partition (everything in one partition, or a globally unique value per row) forces either a full table scan or defeats the purpose of partitioning entirely.

// ✅ Good pattern — narrow partition scope, small in-memory filter set
var da = new DataAccess<DynamicEntity>();

var userEvents = (await da.GetCollectionAsync($"user-{userId}"))   // Server: one user's events
    .Where(e => e["EventType"]?.ToString() == "Purchase"           // Memory: purchase events only
             && DateTime.TryParse(e["CreatedAt"]?.ToString(),       // Memory: last 7 days
                    out var dt) && dt >= DateTime.UtcNow.AddDays(-7))
    .OrderByDescending(e => e["CreatedAt"]?.ToString())
    .Take(10)
    .ToList();

14. Azure Blob Storage

AzureBlobs provides a full-featured Azure Blob Storage client with tag-based indexing, lambda expression filtering, multi-file operations, FFmpeg video support, and stream uploads.

Use Blob Storage for large content. Any property that would exceed ~50 KB serialized — HTML, source code, binary files, large text, serialized datasets — belongs in Blob Storage. Store the blob URL as a string property on the entity. This is how you keep entity sizes lean and use the correct storage primitive for each data type.

Instantiation

var blobs = new AzureBlobs("documents");                                       // Default credentials
var blobs = new AzureBlobs("videos", defaultMaxFileSizeBytes: 500*1024*1024);  // Custom size limit
var blobs = new AzureBlobs("accountName", "accountKey", "documents");          // Explicit credentials

Upload

// From file path
Uri uri = await blobs.UploadFileAsync(
    filePath:  "/tmp/invoice.pdf",
    indexTags: new Dictionary<string, string> { ["Year"] = "2025", ["Status"] = "Pending" },
    metadata:  new Dictionary<string, string> { ["UploadedBy"] = "system" }
);

// From stream
Uri uri = await blobs.UploadStreamAsync(stream, "photo.jpg",
    indexTags: new Dictionary<string, string> { ["UserId"] = "user-123" });

// From string (HTML, CSS, JS, source code, text)
Uri uri = await blobs.UploadStringDataAsync("report.html", "<html>...</html>", "text/html",
    tags: new Dictionary<string, string> { ["Year"] = "2025" });

// Multiple files
var results = await blobs.UploadMultipleFilesAsync(fileList);

Query with lambda expressions on tags

List<BlobData> list = await blobs.GetCollectionAsync(
    x => x.Tags["Year"] == "2025" && x.Tags["Department"] == "Finance"
);

// Streaming — start processing before download completes
await foreach (var blob in blobs.GetCollectionAsyncEnumerable(
    x => x.Tags["Status"] == "Approved", loadContent: false))
{
    Console.WriteLine($"{blob.Name} — {blob.Size} bytes");
}

Blob tag limits: Maximum 10 index tags per blob. Plan your 10 tags around your most important query dimensions.

Search, download, delete, and tag management

// Search
var results = await blobs.SearchBlobsByTagsAsync("Year = '2025' AND Status = 'Active'");
var results = await blobs.SearchBlobsAsync("invoice", tagFilters, startDate, endDate);

// Download
bool ok             = await blobs.DownloadFileAsync("invoice.pdf", "/tmp/invoice.pdf");
BlobData? blob      = await blobs.GetBlobWithContentAsync("invoice.pdf");
List<BlobData> many = await blobs.GetBlobsWithContentAsync(new[] { "a.pdf", "b.pdf" });

// Delete
bool ok      = await blobs.DeleteBlobAsync("invoice.pdf");
var results  = await blobs.DeleteMultipleBlobsAsync(new[] { "a.pdf", "b.pdf" });
await blobs.DeleteMultipleBlobsAsync(x => x.Tags["Status"] == "Expired");

// Tags
await blobs.UpdateBlobTagsAsync("invoice.pdf", new Dictionary<string, string> { ["Status"] = "Paid" });
var tags = await blobs.GetBlobTagsAsync("invoice.pdf");

// List
var all = await blobs.ListBlobsAsync(prefix: "invoices/2025/", loadContent: false);
await foreach (var b in blobs.ListBlobsAsyncEnumerable("invoices/")) { }

File type restrictions

blobs.AddAllowedFileType(".pdf", "application/pdf");
blobs.AddAllowedFileType(".jpg", "image/jpeg");
bool ok = blobs.IsFileTypeAllowed("document.pdf");
blobs.RemoveAllowedFileType(".pdf");

FFmpeg video support

// Auto-compress if upload exceeds size limit
Uri uri = await blobs.UploadStreamAsync(videoStream, "demo.mp4",
    maxFileSizeBytes: 50 * 1024 * 1024);

// Direct FFmpeg operations
byte[] compressed = await FFmpegManager.CompressVideoAsync(videoBytes, targetBitrateKbps: 2000);
byte[] thumbnail  = await FFmpegManager.ExtractThumbnailAsync(videoBytes, timeSeconds: 3.0);
byte[] stitched   = await FFmpegManager.StitchVideosAsync(videoChunks, "output.mp4");

Blob streaming with IAsyncEnumerable — process results as they arrive

AzureBlobs exposes three IAsyncEnumerable<BlobData> methods that stream results from Azure as they are found rather than waiting for the entire result set to be assembled in memory first. This is a fundamentally different execution model from the standard GetCollectionAsync / ListBlobsAsync methods, and it enables scenarios those methods cannot support efficiently.

The difference in plain terms: GetCollectionAsync fetches all matching blobs, builds the full list, then hands it to you. GetCollectionAsyncEnumerable hands you each blob the moment Azure returns it — your processing code runs in parallel with Azure's continued search. For a container with 10,000 blobs, the first result arrives in milliseconds and you begin working immediately. With the non-streaming version you wait until all 10,000 are fetched.

All three streaming methods accept a CancellationToken, which means the entire operation — including the ongoing Azure query — can be cancelled mid-stream without waiting for completion.

// Stream by lambda tag filter — yields each matching blob as Azure finds it
await foreach (var blob in blobs.GetCollectionAsyncEnumerable(
    x => x.Tags["Year"] == "2025" && x.Tags["Department"] == "Finance",
    loadContent: false,          // Don't download bytes — metadata only for fast scanning
    cancellationToken: cts.Token))
{
    Console.WriteLine($"{blob.Name} — {blob.Size:N0} bytes");
}

// Stream all blobs in a container or prefix — useful for inventory and migration
await foreach (var blob in blobs.ListBlobsAsyncEnumerable(
    prefix:      "invoices/2025/",
    loadContent: false,
    cancellationToken: cts.Token))
{
    await ProcessInvoiceMetadataAsync(blob);
}

// Stream by raw tag query string
await foreach (var blob in blobs.SearchBlobsByTagsAsyncEnumerable(
    tagQuery:          "Status = 'Pending' AND Priority = 'High'",
    loadContent:       true,   // Download content for each blob — use only when needed
    cancellationToken: cts.Token))
{
    await ProcessDocumentAsync(blob.Data!);
}

When to use streaming vs standard collection methods

Scenario Use
Large container scan where you want to start processing immediately GetCollectionAsyncEnumerable
Building a real-time dashboard that populates as results arrive GetCollectionAsyncEnumerable
Feeding a Blazor or SignalR component that renders items progressively GetCollectionAsyncEnumerable
Processing pipeline where each item triggers async work GetCollectionAsyncEnumerable
Bulk export or migration where stopping mid-way must cancel the Azure query ListBlobsAsyncEnumerable + CancellationToken
User-initiated search with a cancel button Any AsyncEnumerable variant + CancellationToken
Small result set where you need all items before proceeding GetCollectionAsync
Displaying a complete sorted/grouped list in a UI GetCollectionAsync

Streaming with early exit — processing until a condition is met

Because IAsyncEnumerable is a lazy pull sequence, you can stop consuming it at any point with break and the Azure query is cancelled cleanly. No wasted network traffic for results you will never use:

// Find the first 5 blobs over 10MB — stop as soon as we have them
var largeFiles = new List<BlobData>();

await foreach (var blob in blobs.ListBlobsAsyncEnumerable("uploads/", loadContent: false))
{
    if (blob.Size > 10 * 1024 * 1024)
        largeFiles.Add(blob);

    if (largeFiles.Count >= 5)
        break;  // Cancels the Azure enumeration — no further network calls
}

Streaming into a Blazor component

The async enumerable pattern maps naturally to progressive UI rendering in Blazor. The component begins rendering results immediately without waiting for the full dataset:

// In a Blazor component
private List<BlobData> _documents = new();

protected override async Task OnInitializedAsync()
{
    var blobs = new AzureBlobs("documents");

    await foreach (var doc in blobs.GetCollectionAsyncEnumerable(
        x => x.Tags["ClientId"] == ClientId,
        loadContent: false))
    {
        _documents.Add(doc);
        StateHasChanged();  // Re-render after each item — UI populates progressively
        await Task.Yield(); // Yield control back to the renderer between items
    }
}

Memory profile comparison

For a container with 50,000 blobs:

Method Peak memory Time to first result Cancellable mid-operation
ListBlobsAsync() ~50,000 objects allocated before processing After all 50,000 fetched No
ListBlobsAsyncEnumerable() 1 object at a time Milliseconds Yes

For large containers or any scenario where the full result set would be unwieldy in memory, the streaming variants are the correct choice. They are not a convenience alias for the list methods — they represent a genuinely different and more efficient execution path.


15. Queue Management with StateList — Resumable Work Queues

QueueData<T> and StateList<T> provide a persistent, resumable work queue backed by Azure Table Storage — replacing Azure Service Bus or Azure Queue Storage for scenarios where you need position tracking and crash recovery without message broker complexity.

Queue entity size applies. The serialized StateList<T> is stored as a JSON property. For very large queues, split work across multiple QueueData<T> instances grouped by the same Name (PartitionKey).

StateList<T>

var list = new StateList<Order>(orders);

list.MoveNext();                        // Forward — returns true if successful
list.MovePrevious();                    // Backward
list.JumpTo(42);                        // Jump to index
list.ResetNavigation();                 // Reset to before first item

Order? current = list.Current;
int    index   = list.CurrentIndex;     // -1 if not started

var checkpoint = list.GetCheckpoint();
list.RestoreCheckpoint(checkpoint);     // Roll back to saved position

List<Order> remaining = list.GetRemainingItems();

list.Add(newOrder);
list.AddRange(moreOrders);
list.RemoveAll(o => o.Status == "Cancelled");
list.Sort((a, b) => a.CreatedDate.CompareTo(b.CreatedDate));

StateList<Order> sl = orders.ToStateList();

QueueData<T> — create and inspect

var queue = QueueData<Order>.CreateFromList(pendingOrders, name: "PendingOrders");
await queue.SaveQueueAsync();

var queue = QueueData<Order>.CreateFromStateList(stateList, name: "OrderBatch-2025-04");
await queue.SaveQueueAsync();

var queue = await QueueData<Order>.GetQueueAsync(queueId);

Console.WriteLine($"Status:   {queue.ProcessingStatus}");    // "In Progress (42 of 500)"
Console.WriteLine($"Progress: {queue.PercentComplete:F1}%");
Console.WriteLine($"Items:    {queue.TotalItemCount}");
Console.WriteLine($"Position: {queue.LastProcessedIndex}");

Processing with crash recovery

var queue = await QueueData<Order>.GetQueueAsync(queueId);
if (queue == null) return;

while (queue.Data.MoveNext())
{
    var order = queue.Data.Current!;
    try
    {
        await ProcessOrderAsync(order);

        if ((queue.Data.CurrentIndex + 1) % 10 == 0)
            await queue.SaveQueueAsync();  // Persist position — survives crashes
    }
    catch
    {
        await queue.SaveQueueAsync();
        throw;
    }
}

await QueueData<Order>.DeleteQueueAsync(queueId);

Retrieval and batch management

var q   = await QueueData<Order>.GetQueueAsync(queueId);
var p   = await QueueData<Order>.PeekQueueAsync(queueId);       // Non-destructive
var gd  = await QueueData<Order>.GetAndDeleteQueueAsync(queueId); // One-shot
var all = await QueueData<Order>.GetQueuesAsync("PendingOrders");

int deleted = await QueueData<Order>.DeleteQueuesAsync(new List<string> { id1, id2 });
int deleted = await QueueData<Order>.DeleteQueuesMatchingAsync(q => q.PercentComplete == 100);
List<StateList<Order>> data = await QueueData<Order>.DeleteAndReturnAllAsync("PendingOrders");

16. Session Management — Taking Over All Session State in Your App

ASCDataAccess does not just provide a Session class — it provides a complete session management takeover for any .NET application type. Once configured, you interact with session state using the same familiar API regardless of whether you are building a web app, a desktop app, a console tool, or a background service. The session layer automatically detects the platform, manages session IDs in the right storage mechanism for that platform, batches writes to Azure Table Storage, and cleans up stale data on a configurable schedule.

The key principle: you do not manage session objects directly in your application code. You register the session infrastructure once in Program.cs and then access session state anywhere in your app through SessionManager.Current — one line that always returns the right session for the current user, regardless of platform.

How SessionManager works

SessionManager.Current is the unified entry point. It automatically determines the correct session ID for the current execution context using this priority chain:

  1. Authenticated user identity (web apps) — the authenticated user's Identity.Name or NameIdentifier claim
  2. Session cookie (web apps, unauthenticated) — reads or creates a HttpOnly cookie scoped to the browser
  3. Explicitly configured session ID (desktop / console)
  4. Custom ID provider — your own Func<string> for special scenarios
  5. Platform-specific detectionUsername_MachineName for desktop; MachineName_ProcessId for services
  6. Generated fallback — a stable GUID persisted to LocalApplicationData for survival across restarts

Sessions are stored in a ConcurrentDictionary keyed by session ID — fully thread-safe and designed to scale to thousands of simultaneous users without contention.

SessionIdStrategy — choosing the right strategy for your app

Strategy Best for Session ID source
Auto Let the library detect the platform Platform-appropriate automatic detection
HttpContext ASP.NET Core web apps HTTP cookie (per browser) or authenticated user identity
UserMachine Desktop apps Username_MachineName — stable across app restarts
MachineProcess Windows services, background workers MachineName_ProcessId
Custom Multi-tenant, special routing Your own Func<string> provider
Guid Isolated one-off sessions New GUID per session

Program.cs setup — ASP.NET Core web application

using ASCTableStorage;
using ASCTableStorage.Sessions;

// ── 1. Global DAL credentials ──────────────────────────────────────────────
TableStorage.Configure(
    builder.Configuration["Azure:TableStorageName"]!,
    builder.Configuration["Azure:TableStorageKey"]!
);

// ── 2. Logging — first-class ILogger provider writing to Azure Table Storage
builder.Logging.ClearProviders();
builder.Logging.AddAzureTableLogging(options =>
{
    options.AccountName   = builder.Configuration["Azure:TableStorageName"]!;
    options.AccountKey    = builder.Configuration["Azure:TableStorageKey"]!;
    options.MinimumLevel  = LogLevel.Warning;
    options.RetentionDays = 60;
});

// ── 3. Session management — takes over ALL session state for the application
builder.Services.AddAzureTableSessions(options =>
{
    options.AccountName       = builder.Configuration["Azure:TableStorageName"]!;
    options.AccountKey        = builder.Configuration["Azure:TableStorageKey"]!;
    options.IdStrategy        = SessionIdStrategy.HttpContext; // Cookie per browser / user identity
    options.CleanupInterval   = TimeSpan.FromMinutes(30);
    options.BatchWriteDelay   = TimeSpan.FromMilliseconds(500);
    options.AutoCommitInterval = TimeSpan.FromMilliseconds(500);
    options.StaleDataCleanupAge = TimeSpan.FromMinutes(45);
    options.EnableAutoCleanup = true;
});

// ── 4. Build the app ────────────────────────────────────────────────────────
var app = builder.Build();

// ── 5. Post-build initialization ────────────────────────────────────────────
// RemoteLogger must be wired after DI is built so ILoggerFactory is available
RemoteLogger.Initialize(app.Services.GetRequiredService<ILoggerFactory>());

// ── IMPORTANT: Do NOT call app.UseSession()
// AzureTableSessions registers its own middleware and manages session state
// internally. Calling app.UseSession() would conflict with it.

Program.cs setup — desktop application (WinForms, WPF, Avalonia)

Desktop apps do not have a DI container or IHostBuilder by default, but SessionManager works without one. The session ID is derived from Username_MachineName, persisted to LocalApplicationData, and survives application restarts.

using ASCTableStorage;
using ASCTableStorage.Sessions;
using ASCTableStorage.Logging;
using Microsoft.Extensions.Logging;

// ── 1. Global DAL credentials ──────────────────────────────────────────────
TableStorage.Configure("your-account-name", "your-account-key");

// ── 2. Logging ─────────────────────────────────────────────────────────────
var loggerFactory = LoggerFactory.Create(logging =>
{
    logging.ClearProviders();
    logging.AddAzureTableLogging(options =>
    {
        options.AccountName   = "your-account-name";
        options.AccountKey    = "your-account-key";
        options.MinimumLevel  = LogLevel.Information;
        options.RetentionDays = 30;
    });
});

// Wire RemoteLogger so the rest of the DAL can log through it
RemoteLogger.Initialize(loggerFactory);

// ── 3. Session management ──────────────────────────────────────────────────
await SessionManager.InitializeAsync(
    "your-account-name",
    "your-account-key",
    options =>
    {
        options.IdStrategy   = SessionIdStrategy.UserMachine; // Username_MachineName
        options.AutoCommitInterval = TimeSpan.FromSeconds(2);
        options.EnableAutoCleanup  = true;
        options.CleanupInterval    = TimeSpan.FromHours(1);
    }
);

// ── 4. Application entry point ─────────────────────────────────────────────
// Now SessionManager.Current is available anywhere in the app
Application.Run(new MainForm());

Program.cs setup — console / background service

using ASCTableStorage;
using ASCTableStorage.Sessions;

var host = Host.CreateDefaultBuilder(args)
    .ConfigureServices((context, services) =>
    {
        // ── 1. Session management via IHostBuilder extension ───────────────
        services.AddAzureTableSessions(options =>
        {
            options.AccountName  = context.Configuration["Azure:TableStorageName"]!;
            options.AccountKey   = context.Configuration["Azure:TableStorageKey"]!;
            options.IdStrategy   = SessionIdStrategy.MachineProcess;
            options.EnableAutoCleanup = true;
        });
    })
    .ConfigureLogging((context, logging) =>
    {
        logging.ClearProviders();
        logging.AddAzureTableLogging(options =>
        {
            options.AccountName  = context.Configuration["Azure:TableStorageName"]!;
            options.AccountKey   = context.Configuration["Azure:TableStorageKey"]!;
            options.MinimumLevel = LogLevel.Information;
        });
    })
    .Build();

TableStorage.Configure(
    host.Services.GetRequiredService<IConfiguration>()["Azure:TableStorageName"]!,
    host.Services.GetRequiredService<IConfiguration>()["Azure:TableStorageKey"]!
);

RemoteLogger.Initialize(host.Services.GetRequiredService<ILoggerFactory>());

await host.RunAsync();

Using session state anywhere in your application

Once initialized, access session state through SessionManager.Current from anywhere — a controller, a service, a background task, or a form. The correct session is always returned for the current user context automatically.

// ── Store any typed value ──────────────────────────────────────────────────
var session = SessionManager.Current;

session.SetString("Theme",  "dark");
session.SetInt32("PageSize", 25);
session.SetObject("UserPreferences", new UserPrefs { Language = "en", ShowHelp = true });

// ── Retrieve typed values ──────────────────────────────────────────────────
string? theme    = session.GetString("Theme");
int?    pageSize = session.GetInt32("PageSize");
UserPrefs? prefs = session.GetObject<UserPrefs>("UserPreferences");

// ── DateTime helpers ───────────────────────────────────────────────────────
session.SetString("LastLogin", DateTime.UtcNow.ToString("O"));
DateTime lastLogin    = session.GetDateTime("LastLogin");
DateTime lastLoginUtc = session.GetUtcDateTime("LastLogin");

// ── Raw AppSessionData access (when you need full control) ─────────────────
session["CartTotal"] = new AppSessionData { Value = 149.99m };
var cartData = session["CartTotal"];
decimal total = (decimal)cartData!.Value!;

// ── Check, remove, and clear ───────────────────────────────────────────────
bool exists  = session.ContainsKey("Theme");
bool removed = await session.RemoveAsync("Theme");
await session.ClearAsync();   // Wipes all data for this session
await session.FlushAsync();   // Force immediate write to Azure (bypass batch delay)

Commit behavior — automatic and manual

By default, session writes are batched and flushed to Azure Table Storage every 500ms (AutoCommitInterval). This prevents chatty per-keystroke writes under rapid updates. You can also commit explicitly:

// Manual commit — use when you need the data persisted immediately
await session.CommitDataAsync();

// Check if uncommitted changes exist
bool dirty = !session.DataHasBeenCommitted;

// Refresh from Azure — picks up changes made by another process or request
await session.RefreshSessionDataAsync();

// Full session restart — clears all stored data for this session ID
await session.RestartSessionAsync();

Multi-session management

SessionManager maintains isolated sessions for every concurrent user automatically. You can also explicitly access any session by ID — useful in admin tools, impersonation, or multi-user desktop apps:

// Access a specific session by ID
Session adminView = SessionManager.GetSession("user-456-session");

// Find and clean up stale sessions
List<string> staleIds = await SessionManager.Current.GetStaleSessionsAsync();
foreach (var id in staleIds)
{
    var stale = SessionManager.GetSession(id);
    await stale.RestartSessionAsync();
}

// Statistics
var stats = SessionManager.GetStatistics();
Console.WriteLine($"Total writes: {stats?.TotalWrites}");

SessionOptions reference

Option Default Description
AccountName Required Azure Storage account name
AccountKey Required Azure Storage account key
IdStrategy Auto How session IDs are determined
CustomIdProvider null Func<string> for Custom strategy
TableName AppSessionData Azure table name for session storage
AutoCommitInterval 500ms How often pending writes are flushed
StaleDataCleanupAge 45min Age at which session rows are considered stale
CleanupInterval 30min How often the cleanup background job runs
CleanupStartDelay 30sec Delay before first cleanup run after startup
EnableAutoCleanup true Run automatic stale data cleanup
TrackActivity true Track session activity for stale detection
ApplicationName null Optional grouping label

17. Logging — A First-Class ILogger Provider

The most important thing to understand about logging in ASCDataAccess: you do not use ErrorLogData directly in your day-to-day application code. AzureTableLogger is a first-class ILogger provider that plugs into the standard .NET logging pipeline. Once registered, you log exactly the same way you do with any other .NET logger — via constructor injection, ILoggerFactory, or RemoteLogger.CreateLogger<T>(). All of that writes to Azure Table Storage automatically, with background batching, automatic retention cleanup, and per-level retention policies.

ErrorLogData is a secondary, specialized API for cases where you want to write a rich structured error record — with customer ID, session ID, caller info, and a full exception trace — into a dedicated error table separate from the application log stream. It is not the everyday logging path.

Register the logger — ASP.NET Core

This registration replaces the default console/debug providers with Azure Table Storage. Add it to your Program.cs builder block before app.Build():

builder.Logging.ClearProviders();
builder.Logging.AddAzureTableLogging(options =>
{
    options.AccountName       = builder.Configuration["Azure:TableStorageName"]!;
    options.AccountKey        = builder.Configuration["Azure:TableStorageKey"]!;
    options.MinimumLevel      = LogLevel.Warning;       // Trace, Debug, Information, Warning, Error, Critical
    options.RetentionDays     = 60;                     // Default retention for all levels
    options.EnableAutoCleanup = true;                   // Runs cleanup on a background timer
    options.CleanupInterval   = TimeSpan.FromDays(1);  // How often cleanup runs
    options.RetentionByLevel  = new Dictionary<LogLevel, int>
    {
        [LogLevel.Information] = 7,    // Information logs kept 7 days
        [LogLevel.Warning]     = 30,   // Warning logs kept 30 days
        [LogLevel.Error]       = 90,   // Error logs kept 90 days
        [LogLevel.Critical]    = 365   // Critical logs kept 1 year
    };
});

Wire RemoteLogger after build

RemoteLogger is the DAL's internal logger bridge — it allows DAL classes (session manager, batch operations, etc.) to emit logs through your registered ILoggerFactory. Wire it in the post-build initialization block:

var app = builder.Build();

// Must be called after Build() so ILoggerFactory is available from DI
RemoteLogger.Initialize(app.Services.GetRequiredService<ILoggerFactory>());

Register the logger — desktop / console (non-DI)

var loggerFactory = LoggerFactory.Create(logging =>
{
    logging.ClearProviders();
    logging.AddAzureTableLogging(options =>
    {
        options.AccountName   = "your-account-name";
        options.AccountKey    = "your-account-key";
        options.MinimumLevel  = LogLevel.Information;
        options.RetentionDays = 30;
    });
});

// Wire RemoteLogger so DAL internals can log through this factory
RemoteLogger.Initialize(loggerFactory);

Using the logger — standard .NET logging API throughout your app

Once registered, inject ILogger<T> anywhere in your application — controllers, services, background workers, anywhere. This is identical to using any other .NET logger:

public class OrderService
{
    private readonly ILogger<OrderService> _logger;

    public OrderService(ILogger<OrderService> logger)
    {
        _logger = logger;
    }

    public async Task ProcessOrderAsync(Order order)
    {
        _logger.LogInformation("Processing order {OrderId} for customer {CustomerId}",
            order.OrderId, order.CustomerId);

        try
        {
            // ... processing logic ...

            _logger.LogInformation("Order {OrderId} completed. Total: {Total:C}",
                order.OrderId, order.Total);
        }
        catch (PaymentException ex)
        {
            _logger.LogError(ex, "Payment failed for order {OrderId}. Amount: {Amount:C}",
                order.OrderId, order.Amount);
            throw;
        }
        catch (Exception ex)
        {
            _logger.LogCritical(ex, "Unexpected failure processing order {OrderId}", order.OrderId);
            throw;
        }
    }
}

All of that writes to Azure Table Storage — no other configuration needed. The logger is the provider. You write standard .NET structured log messages; ASCDataAccess handles the persistence.

Getting a logger outside of DI

RemoteLogger provides static access to loggers for code that is not wired through DI — legacy code, static helpers, extension methods:

// By type name (category = "MyNamespace.MyComponent")
ILogger<MyComponent> typed = RemoteLogger.CreateLogger<MyComponent>();

// By explicit category string
ILogger logger = RemoteLogger.CreateLogger("Security.Suspicion");
ILogger logger = RemoteLogger.CreateLogger("BackgroundSync");

// Then use normally
logger.LogWarning("Rate limit exceeded for IP {IpAddress}", ip);
logger.LogError(ex, "Sync failed for tenant {TenantId}", tenantId);

Log level guide

Level When to use
LogTrace Verbose diagnostic data — disabled in production
LogDebug Developer diagnostics — disabled in production
LogInformation Normal application events: user logged in, order placed
LogWarning Recoverable issues: retry occurred, rate limit approaching
LogError Operation failed but application continues: payment declined, API timeout
LogCritical Application-level failure requiring immediate attention: data corruption, auth service down

Log partitioning

Logs are automatically partitioned by YYYY-MM timestamp bucket. This means:

  • Querying logs by date range is fast (PartitionKey scan, not full table scan)
  • Retention cleanup deletes by partition — no row-by-row scanning
  • High log volumes distribute across monthly partitions automatically

18. Structured Error Records — ErrorLogData

ErrorLogData is the specialized API for capturing a rich, structured error record in a dedicated error table, separate from the application log stream. Use it when you need a record that carries business context — customer ID, session ID, company ID, caller info — that your standard ILogger call does not include.

The everyday logging case is _logger.LogError(ex, "message"). Use ErrorLogData when you need a queryable, business-context-enriched error record that a support team or automated system can retrieve by customer or session.

// Structured error with business context — writes to dedicated error table
var error = ErrorLogData.CreateWithCallerInfo(
    "Payment processing failed",
    ErrorCodeTypes.Critical,
    customerID: "CUST-001",
    sessionID:  "sess-abc"
);
await error.LogErrorAsync();  // Uses TableStorage credentials; also runs retention cleanup

// With structured state object and exception
var error = ErrorLogData.CreateWithCallerInfo(
    state:     new { OrderId = "ORD-789", Amount = 599.99, Gateway = "Stripe" },
    exception: ex,
    formatter: (s, ex) => $"Order {s.OrderId} for ${s.Amount} failed via {s.Gateway}: {ex?.Message}",
    severity:  ErrorCodeTypes.Error
);
await error.LogErrorAsync();

// Retention cleanup (also runs automatically after each LogErrorAsync call)
await ErrorLogData.ClearOldDataAsync("accountName", "accountKey", daysOld: 60);
await ErrorLogData.ClearOldDataByType("accountName", "accountKey",
    ErrorCodeTypes.Information, daysOld: 7);

ErrorCodeTypes

Value Description
Information General informational events
Warning Non-critical issues requiring attention
Error Errors that affect functionality
Critical Severe errors requiring immediate intervention
Unknown Fallback / unclassified

When to use ILogger vs ErrorLogData

Scenario Use
Normal application event (user action, process step) _logger.LogInformation(...)
Recoverable issue (retry, fallback used) _logger.LogWarning(...)
Operation failure (exception caught and handled) _logger.LogError(ex, ...)
Application-level failure (service down) _logger.LogCritical(ex, ...)
Business error needing customer/session context for support ErrorLogData.CreateWithCallerInfo(...)
Error that a support system queries by customer ID ErrorLogData.CreateWithCallerInfo(...)

19. Advanced: TableOptions, Multi-Account, and Data Migration

// Override table name and PartitionKey property
var da = new DataAccess<Order>(new TableOptions
{
    TableName                = "OrdersArchive",
    PartitionKeyPropertyName = "RegionId"
    // Null values fall back to TableStorage.AccountName / AccountKey
});

// Explicit different account (migrations, multi-tenant)
var da = new DataAccess<Order>(new TableOptions
{
    TableName        = "Orders",
    TableStorageName = "migration-account",
    TableStorageKey  = "migration-key"
});

// Data migration between accounts
var source = new DataAccess<Customer>("source-account", "source-key");
var dest   = new DataAccess<Customer>("dest-account",   "dest-key");
var data   = await source.GetAllTableDataAsync();
await dest.BatchUpdateListAsync(data, TableOperationType.InsertOrReplace);

20. Automatic Type Handling

Decimal precision

Stored as long × 10,000. Preserves 4 decimal places exactly. Transparent on read and write.

123.4567m  → stored as 1234567L
0.0001m    → stored as 1L

Large string chunking (> 32 KB)

Split into sequential properties; reassembled on read. Each chunk consumes one of 255 property slots and contributes to the 1 MB entity budget.

MyText        → first 32 KB   (slot 1)
MyText_pt_1   → next 32 KB    (slot 2)
MyText_pt_2   → remainder     (slot 3)

Complex type serialization

Custom classes, List<T>, Dictionary<K,V> — JSON-serialized to a single string column. Limitation: lambda expressions cannot filter inside the serialized JSON. See section 6.

Enum serialization

Stored as string name — human-readable, survives renumbering.

Boolean and DateTime

Booleans parsed from true/false, 1/0, yes/no. DateTimes parsed intelligently from multiple historical formats.


21. Complete API Reference

TableStorage static class

Member Description
TableStorage.Configure(name, key) Set global credentials — call once in Program.cs
TableStorage.AccountName Get configured account name
TableStorage.AccountKey Get configured account key
TableStorage.IsConfigured Check if Configure has been called

DataAccess<T> — constructors

Signature Description
DataAccess() Uses TableStorage.AccountName/Key
DataAccess(string name, string key) Explicit credentials
DataAccess(TableOptions options) Full configuration override

DataAccess<T> — CRUD

Method Description
ManageDataAsync(T, TableOperationType) Async upsert/delete single typed entity
ManageDataAsync(object, TableOperationType) Async upsert/delete any object via DynamicEntity
ManageData(T, TableOperationType) Sync variant
ManageData(object, TableOperationType) Sync variant

DataAccess<T> — queries

Method Description
GetRowObjectAsync(string rowKey) O(1) fetch by RowKey — always prefer
GetRowObjectAsync(Expression<Func<T,bool>>) Fetch by lambda (hybrid)
GetRowObjectAsync(field, ComparisonTypes, value) Fetch by field comparison
GetCollectionAsync(string partitionKey) All rows in a partition — fast scan
GetCollectionAsync(Expression<Func<T,bool>>) Lambda query (hybrid)
GetCollectionAsync(List<DBQueryItem>, QueryCombineStyle) Dynamic multi-field query
GetCollectionByFilterAsync(string odataFilter) Raw OData filter
GetAllTableDataAsync() Full table scan — use sparingly
All async methods have sync equivalents Remove Async suffix

DataAccess<T> — batch

Method Description
BatchUpdateListAsync(List<T>, TableOperationType, IProgress?) Async batch typed
BatchUpdateListAsync(List<DynamicEntity>, TableOperationType, IProgress?) Async batch dynamic
BatchUpdateList(List<T>, TableOperationType) Sync batch typed
BatchUpdateList(List<DynamicEntity>, TableOperationType) Sync batch dynamic

DataAccess<T> — pagination

Method Description
GetPagedCollectionAsync(int pageSize, string? token, string? filter) Page by OData filter
GetPagedCollectionAsync(string partitionKey, int pageSize, string? token) Page by partition
GetPagedCollectionAsync(Expression<Func<T,bool>>, int pageSize, string? token) Page by lambda
GetInitialDataLoadAsync(int size, string? filter) Quick initial load
GetInitialDataLoadAsync(Expression<Func<T,bool>>, int size) Quick initial load by lambda

TableOperationType

Value Behavior
InsertOrReplace Upsert — full replace if exists (default)
InsertOrMerge Upsert — merge properties if exists
Delete Delete the entity

ComparisonTypes

Value Meaning
eq Equal
ne Not Equal
gt Greater Than
ge Greater Than or Equal
lt Less Than
le Less Than or Equal

Extension methods (ASCTableStorage.Common.Extensions)

Method Scope Direction
entity.Load(rowKey) Single Read sync
entity.Load(lambda) Single Read sync
entity.Save(direction?) Single Write sync
entity.Delete(direction?) Single Delete sync
entity.LoadAsync(rowKey) Single Read async
entity.LoadAsync(lambda) Single Read async
entity.SaveAsync(direction?) Single Write async
entity.DeleteAsync(direction?) Single Delete async
collection.Load(lambda) List Read sync
collection.Save(direction?) List Write sync
collection.LoadAsync(lambda) List Read async
collection.SaveAsync(direction?) List Write async
collection.DeleteAsync(direction?) List Delete async
stateList.Save(direction?) StateList Write sync
stateList.SaveAsync(direction?) StateList Write async
stateList.DeleteAsync(direction?) StateList Delete async
enumerable.ToStateList() Any IEnumerable Convert
errorLog.LogErrorAsync() ErrorLogData Write async

22. Design Patterns, Best Practices, and Anti-Patterns

✅ Best Practices

Map PartitionKey and RowKey to meaningful business properties

// ✅ Semantic clarity — the Azure key model mapped to your domain
public string CompanyId  { get => PartitionKey ?? string.Empty; set => PartitionKey = value; }
public string CustomerId { get => RowKey ?? string.Empty;       set => RowKey = value; }

Use GetRowObjectAsync(rowKey) whenever you have the key

// ✅ O(1) — fastest operation in the entire library
var customer = await da.GetRowObjectAsync(customerId);

Batch all bulk writes — never loop ManageDataAsync

// ✅ One batch call — auto-chunked at 100 per Azure transaction
await da.BatchUpdateListAsync(largeList);

Move large content to Blob Storage; store the URL

// ✅ Entity stays lean; content is in the right storage tier
public string? HtmlContentUrl { get; set; }  // Points to AzureBlobs

Pre-compute aggregates into summary entities at write time

// ✅ Summary entity updated on each write — instant read performance
public class MonthlySummary : TableEntityBase, ITableExtra
{
    public string CompanyId  { get => PartitionKey!; set => PartitionKey = value; }
    public string YearMonth  { get => RowKey!;       set => RowKey = value; }
    public int    OrderCount { get; set; }
    public decimal Revenue   { get; set; }
    public string TableReference => "MonthlySummaries";
    public string GetIDValue()   => YearMonth;
}

Use explicit boolean comparisons in lambda queries

// ✅ Always explicit
await da.GetCollectionAsync(x => x.IsActive == true);

❌ Anti-Patterns

Instantiating DataAccess<T> for simple load/save/delete

// ❌ Unnecessary boilerplate — DataAccess<T> is not needed for simple operations
var da       = new DataAccess<Customer>();
var customer = await da.GetRowObjectAsync("CUST-042");
await da.ManageDataAsync(customer, TableOperationType.InsertOrReplace);

// ✅ Use extension methods — same result, no ceremony
var customer = await new Customer().LoadAsync("CUST-042");
await customer.SaveAsync();
// ❌ Lambda cannot filter inside JSON — full table scan + broken semantics
public string? OrdersJson { get; set; }
await da.GetCollectionAsync(x => x.OrdersJson.Contains("ORD-1"));

// ✅ Use a related entity
public class Order : TableEntityBase, ITableExtra { ... }

String methods in lambdas on large tables

// ❌ Full table scan
await da.GetCollectionAsync(x => x.Name.Contains("Smith"));

// ✅ Normalize at write time; query by equality
await da.GetCollectionAsync(x => x.NameKey == "smith");

Unbounded property growth toward the 255 limit

// ❌ Dynamic tag fields on the entity — hits 255 limit as tags grow
public string? Tag1 { get; set; }
public string? Tag2 { get; set; }
// ... Tag50 { get; set; }

// ✅ Related entity — scales to any number of tags
public class ProductTag : TableEntityBase, ITableExtra { ... }

"all" or a single value as PartitionKey for all records

// ❌ Everything in one partition — single storage node bottleneck
PartitionKey = "all";

// ✅ Meaningful distribution
PartitionKey = companyId;

Reusing a DataAccess<T> instance across different tables

// ❌ One instance targets one table
var da = new DataAccess<Customer>();  // Only queries the Customers table

// ✅ Separate instances per type
var customerDa = new DataAccess<Customer>();
var orderDa    = new DataAccess<Order>();

Loading entire tables for filtered reads

// ❌ Downloads everything, filters in memory
var all    = await da.GetAllTableDataAsync();
var active = all.Where(x => x.IsActive).ToList();

// ✅ OData filter reduces data returned from Azure
var active = await da.GetCollectionAsync(x => x.IsActive == true);

Storing binary or large text as entity properties

// ❌ Drives entity toward the 1 MB limit
public byte[]?  ThumbnailBytes { get; set; }
public string?  HtmlContent   { get; set; }

// ✅ Blob Storage for content; URL on the entity
public string? ThumbnailUrl { get; set; }
public string? ContentUrl   { get; set; }

Appendix: Migrating from ASCDataAccess v4.0 to v4.1

v4.1 is fully backward-compatible with v4.0. No breaking changes. All changes are additive:

  1. TableStorage.Configure(name, key) — new static class for one-time credential registration
  2. No-arg constructors on DataAccess<T>, AzureBlobs, Session, and QueueData<T>
  3. TableOptions falls back to TableStorage credentials when name/key are null
  4. QueueData<T> static methods have credential-free overloads
  5. Session.CreateAsync(sessionId) and Session(sessionId) — new no-credential overloads

Optional migration steps (v4.0 code continues to work unchanged)

  1. Add TableStorage.Configure(name, key) to Program.cs
  2. Replace new DataAccess<T>(accountName, accountKey)new DataAccess<T>()
  3. Replace new AzureBlobs(name, key, container)new AzureBlobs(container)
  4. Replace new Session(name, key)new Session()
  5. Replace new Session(name, key, id)new Session(id)

ASCDataAccess v4.1 — Developed and maintained by Answer Sales Calls Inc. (ASC)
The only production-quality data access framework for Azure Table Storage in the .NET ecosystem.

Product Compatible and additional computed target framework versions.
.NET net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
4.2.0 41 5/25/2026
4.1.0 127 4/9/2026
4.0.5 128 3/9/2026
4.0.4 166 11/29/2025
4.0.3 156 11/29/2025
4.0.2 224 11/24/2025
4.0.1 236 11/4/2025
4.0.0 248 11/4/2025
3.1.0 275 8/28/2025
3.0.0 215 8/18/2025
2.5.0 190 8/17/2025
2.4.0 183 8/15/2025
2.3.0 227 8/14/2025
2.2.0 224 8/14/2025
2.1.0 221 8/14/2025
2.0.0 331 7/19/2025
1.0.4 250 6/30/2025
1.0.3 200 6/21/2025
1.0.2 241 6/18/2025
1.0.1 279 5/12/2025
Loading failed

## Release Notes — ASCDataAccessLibrary v4.1.0

### New in v4.1

**Target framework: net9.0 — compatible with .NET 9 and .NET 10**
Ships as a net9.0 target. Builds cleanly with .NET SDK 9.x and 10.x.
.NET 6, 7, and 8 consumers receive the net9.0 target via NuGet TFM fallback.
A dedicated multi-target build (net6.0 through net10.0) will be added in a
future release once .NET 10 TFM support stabilises across the NuGet ecosystem.

**Single-configure credential model**
Call TableStorage.Configure(accountName, accountKey) once in Program.cs.
Every DAL class — DataAccess<T>, AzureBlobs, Session, QueueData<T> — resolves
credentials automatically. No more passing accountName/accountKey on every constructor.

**No-arg constructors across all DAL classes**
DataAccess<T>(), AzureBlobs(containerName), Session(), Session(sessionId),
Session.CreateAsync(sessionId), and all QueueData<T> static methods now have
credential-free overloads that resolve from TableStorage.Configure().

**QueueData<T> credential-free static methods**
Every static method (GetQueueAsync, SaveQueueAsync, DeleteQueueAsync,
DeleteQueuesMatchingAsync, DeleteAndReturnAllAsync, etc.) now has a no-credential
overload alongside the existing explicit-credential overloads.
Full backward compatibility — all v4.0 explicit-credential calls continue to work.

**Updated getting started guide**
Complete v4.1 documentation included as package readme. Covers:
 - Extension method pattern as the primary everyday API
 - Full Program.cs setup for web, desktop, and console applications
 - Dynamic entity use cases with lambda query guidance
 - Azure Blob Storage streaming with IAsyncEnumerable
 - Session management takeover for all application types
 - First-class ILogger usage vs ErrorLogData structured records
 - Azure Table Storage hard limits and schema design rules

### v4.0 highlights (for new adopters)
- Migrated from legacy Microsoft.Azure.Cosmos.Table to Azure.Data.Tables SDK v12
- Hybrid query engine: lambda expressions split automatically between server-side OData and client-side filtering
- QueueData<T> + StateList<T> persistent resumable work queues
- Azure Blob Storage with lambda tag filtering and IAsyncEnumerable streaming
- File-based session ID persistence — survives app restarts across all application types
- Fail-safe protection against accidental full table scans

See getting-started-guide-41.md for full API documentation and migration guide.