Level: Intermediate
Last updated: June 2026
Module 1 – Configuring Azure Blob Storage
Blob Storage overview
- Object storage in Azure: hosts any type of file at low cost.
- Each blob has a unique URL. Security configurable for authorized access only.
- Resilient and scalable architecture: multiple copies across multiple servers, multi-region redundancy possible.
- Use cases: media streaming, logs (append blobs), VHD backups, data lakes, Azure Databricks, Power BI.
The 3 blob types
| Type | Description | Max size |
|---|---|---|
| Block Blob | Text or binary files, broken into blocks uploadable in parallel. Modifiable (insert/replace/delete blocks). | 190.7 TB |
| Append Blob | Blocks added only at the end of file. Ideal for log files. | 195 GB |
| Page Blob | Collection of 512-byte pages, optimized for random reads/writes. Used for Azure VM VHDs. | 8 TB |
- Important: The blob type is defined at creation and cannot be changed.
- It is possible to lease a blob (exclusive write lock), create snapshots, and apply immutability policies.
Storage account types
- General Purpose V2 (GPV2): recommended for most cases. Supports blobs, files, tables, queues.
- Premium: SSD performance for ultra-low latency scenarios (page blobs, file shares, block blobs).
Storage account configuration
- Naming: 3–24 characters, lowercase letters and digits only, must be unique across Azure (part of the URL).
- Region: choose based on user location.
- Redundancy: LRS, ZRS, GRS, GZRS.
Containers and access
- Blobs are organized into containers.
- Container access levels:
Private(recommended): authorization required.Blob: anonymous access to blobs only.Container: anonymous access to blobs and container listing.
Blob security and permissions
| Method | Recommendation |
|---|---|
| Anonymous access | Not recommended. Use only for public files. |
| RBAC with Entra ID | ✅ Recommended. Assign roles to security principals (users, groups, apps). Possible at account or container level. |
| Managed Identity | ✅ For apps hosted on Azure (VM, App Service, AKS). No credentials to store. |
| Storage Account Key | Master key. 2 keys for rotation. Do not share or hardcode. |
| Shared Access Signature (SAS) | Time-limited access. Can target precise permissions (read/write). Can be scoped at blob level. |
Access Tiers
Applicable only to block blobs:
| Tier | Usage | Storage cost | Access cost | Minimum duration |
|---|---|---|---|---|
| Hot | Frequently accessed data | High | Low | – |
| Cool | Less frequent data | Medium | Medium | 30 days |
| Cold | Rarely accessed data | Low | High | 90 days |
| Archive | Long-term backups, retrieval in hours | Very low | Very high | 180 days |
Lifecycle Management
- Define rules to automatically move or delete blobs based on:
- Age (days since last modification)
- Last access
- Current tier
- Example: Hot → Cold if not modified in 30 days → Archive if not accessed in 90 days.
- Filters: by name prefix or blob index tags.
Blob Index Tags
- Key/value pairs attached to blobs.
- Allow organizing blobs across different containers.
- Usable as filters in lifecycle policies.
- Max 10 tags per blob. Require additional permissions to be modified.
Blob properties and metadata
- Properties: standard HTTP headers (Cache-Control, Content-Type, Content-Disposition, Content-Encoding) + specific properties (access tier, archive status, legal hold).
- Metadata: custom key/value pairs. Max 8 KB total. No limit on the number of items. Stored with the blob, returned as HTTP headers.
- Difference from Index Tags: metadata does not support built-in search (requires Azure Search). Index tags have built-in search but are limited to 10.
Module 2 – Developing Blob Storage Solutions
Azure Storage Client Libraries (SDK)
- Packages available for .NET, Java, Python, Go, JavaScript (Node.js/browser).
- Encapsulate Azure REST API calls.
- Main .NET classes:
BlobServiceClient: connection to the storage account.BlobContainerClient: operations on a container.BlobClient: operations on an individual blob.AppendBlobClient,PageBlobClient: specific operations for those types.
Authentication with Entra ID (code)
- Use
DefaultAzureCredentialfrom theAzure.Identitylibrary. - In local development: uses the account connected to Azure CLI (
az login). - In Azure production: automatically uses the Managed Identity of the service.
// Recommended connection (no hardcoded credentials)
var credential = new DefaultAzureCredential();
var client = new BlobServiceClient(
new Uri($"https://{accountName}.blob.core.windows.net"),
credential);
Generating a SAS token (code)
// 1. Get a User Delegation Key (valid for the SAS duration)
var userDelegationKey = await blobServiceClient
.GetUserDelegationKeyAsync(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(1));
// 2. Create a BlobSasBuilder
var sasBuilder = new BlobSasBuilder
{
BlobContainerName = containerName,
Resource = "c", // "c" = container, "b" = blob
ExpiresOn = DateTimeOffset.UtcNow.AddDays(1)
};
sasBuilder.SetPermissions(BlobContainerSasPermissions.Read | BlobContainerSasPermissions.Write | BlobContainerSasPermissions.List);
// 3. Create the URI with the signed SAS token
var sasUri = new BlobUriBuilder(containerClient.Uri)
{
Sas = sasBuilder.ToSasQueryParameters(userDelegationKey, accountName)
}.ToUri();
Retry Policy (BlobClientOptions)
var options = new BlobClientOptions
{
Retry = {
Delay = TimeSpan.FromSeconds(0.8), // Delay between attempts (default: 0.8s)
MaxRetries = 5, // Max attempts (default: 5)
Mode = RetryMode.Exponential, // Fixed or Exponential
MaxDelay = TimeSpan.FromMinutes(1), // Max delay (default: 1 min)
NetworkTimeout = TimeSpan.FromSeconds(100) // Network timeout (default: 100s)
}
};
Geo-redundancy for fault tolerance
- Configure GRS or GZRS at account creation.
- In the event of a primary datacenter outage, the secondary takes over.
- The client library can be configured to redirect to the secondary if the primary fails.
Azure Functions + Blob Storage
- Blob Trigger: triggers the function when a blob is uploaded to a container.
- Blob Input Binding: reads a blob as function input.
- BlobOutput Binding: writes a blob as output (function return).
// Example of Blob Trigger + Blob Output
[FunctionName("ThumbnailGenerator")]
[return: BlobOutput("thumbnails/{name}", Connection = "AzureWebJobsStorage")]
public async Task<byte[]> Run(
[BlobTrigger("images/{name}", Connection = "AzureWebJobsStorage")] Stream imageStream,
ILogger log)
{
// Process the image → return a thumbnail as bytes
}
Module 3 – Configuring Azure Cosmos DB
Cosmos DB overview
- Globally distributed NoSQL database, horizontally scalable.
- Multimodal: supports multiple APIs.
- Multi-region replication with configurable consistency levels.
- Guaranteed SLAs: latency < 10ms at P99 for reads/writes in the primary region.
Available APIs
| API | Usage | Query language |
|---|---|---|
| NoSQL (Core SQL) | Native Cosmos DB API. All features available. JSON documents. | SQL-like |
| MongoDB | MongoDB protocol emulation. Easy migration. | MongoDB Query Language |
| Cassandra | Cassandra emulation. Wide-column data. | CQL |
| Apache Gremlin | Graph data (nodes + edges). | Gremlin |
| PostgreSQL | Distributed with PostgreSQL. | SQL |
| Table API | Compatible with Azure Table Storage. | OData |
- Important for the exam: choose the NoSQL API for new Cosmos DB projects (access to all features).
Cosmos DB data model (NoSQL API)
- Account → Database → Container → Items (JSON documents)
- Partition Key: document property that determines the physical partition. Critical for performance.
- Request Units (RUs): abstraction of operation cost (CPU, memory, I/O). Reading 1 KB = 1 RU.
- Throughput: configured in RU/s. Can be Provisioned (guaranteed) or Serverless (on-demand).
Consistency levels
From strongest to weakest:
| Level | Guarantee | Latency | Throughput |
|---|---|---|---|
| Strong | Always reads the most recent data. Synchronized across all regions. | High | Low |
| Bounded Staleness | Data within defined bounds (N versions or T seconds). Order guaranteed. | Medium | Medium |
| Session (default) | Strong consistency for the same client/session. | Low | High |
| Consistent Prefix | Order guaranteed, no staleness bound. | Low | High |
| Eventual | No order or freshness guarantees. Best availability. | Very low | Very high |
- Strong is not available with multi-region writes.
- The level can be changed in the Azure portal.
Module 4 – Developing Cosmos DB Solutions
Cosmos DB .NET SDK
// Connection with Entra ID (recommended in production)
var client = new CosmosClient(
accountEndpoint: "<uri>",
tokenCredential: new DefaultAzureCredential(),
clientOptions: new CosmosClientOptions { ApplicationName = "MyApp" });
// Basic CRUD
var container = client.GetContainer("devdb", "users");
// Create an item
await container.CreateItemAsync(newUser, new PartitionKey(newUser.Id));
// Read an item
var response = await container.ReadItemAsync<User>(id, new PartitionKey(partitionKeyValue));
// SQL query
var query = new QueryDefinition("SELECT * FROM c WHERE c.email = @email")
.WithParameter("@email", email);
using var feed = container.GetItemQueryIterator<User>(query);
while (feed.HasMoreResults)
{
var results = await feed.ReadNextAsync();
// processing
}
// Delete an item
await container.DeleteItemAsync<User>(id, new PartitionKey(partitionKeyValue));
RBAC roles for Cosmos DB (Entra ID)
- Cosmos DB Built-in Data Reader: read-only.
- Cosmos DB Built-in Data Contributor: read/write.
- Assignment via Azure CLI:
az cosmosdb sql role assignment create \
--resource-group <rg> --account-name <account> \
--role-definition-name "Cosmos DB Built-in Data Contributor" \
--principal-id <user-or-sp-id> \
--scope "/dbs/<database>/colls/<container>"
Change Feed
- Ordered log (by partition key) of all inserts and updates in a container.
- By default: deletes are NOT included (use soft delete + TTL).
- Useful for: synchronizing denormalized data, building projections, notifying systems in real time.
Change Feed modes
| Mode | Description |
|---|---|
| Latest Version (default) | Only inserts and updates. Returns the latest version of each item. |
| All Versions and Deletes | Preview. Also includes deletions. |
Lease Container
- Separate container that stores the last read position of the change feed.
- Prevents reprocessing the same data on restart.
Azure Functions – Change Feed Trigger
[FunctionName("OrderTrigger")]
public async Task Run(
[CosmosDBTrigger(
databaseName: "devdb",
containerName: "orders",
Connection = "CosmosConnection",
LeaseContainerName = "leases",
CreateLeaseContainerIfNotExists = true)] IReadOnlyList<Order> changes,
ILogger log)
{
foreach (var order in changes)
{
// Process each modified order
}
}
Change Feed Processor Library
- For full control, self-hosted.
- Use
GetChangeFeedProcessorBuilder()to create the processor. - Define a processing handler (
HandleChangesAsync). - Example: synchronize category names across all associated products.
Key points for the exam
Blob Storage
DefaultAzureCredential: locally → Azure CLI login; in Azure → Managed Identity.- RBAC only works at the container level (not blob). For blob level → use SAS.
- SAS token recommended with User Delegation Key (rather than the storage account key).
- Blob access tiers: Hot, Cool (30d), Cold (90d), Archive (180d).
- Metadata = unlimited count, max 8 KB total. Index Tags = max 10 tags, built-in search.
Cosmos DB
- Choose the NoSQL API for new applications.
- The partition key is critical for performance.
- 1 RU = reading a 1 KB document.
- Change Feed = log of inserts/updates (no deletions by default).
- Bounded Staleness = guaranteed order + freshness within defined bounds.
- Session = strong consistency for the same client, low latency.
- Strong = incompatible with multi-region writes.
Module 5 – Azure Blob Storage SDK (AZ-204 exam focus)
Client class hierarchy
graph TD
A[BlobServiceClient\nstorage account] --> B[BlobContainerClient\ncontainer]
B --> C[BlobClient\ngeneric blob]
B --> D[AppendBlobClient\nappend blob]
B --> E[PageBlobClient\npage blob]
| Class | Responsibility | Typical instantiation |
|---|---|---|
BlobServiceClient | Connection to the storage account, container management | new BlobServiceClient(uri, credential) |
BlobContainerClient | Operations on a container (create, list, delete) | serviceClient.GetBlobContainerClient("my-container") |
BlobClient | CRUD on a blob (upload, download, delete, metadata) | containerClient.GetBlobClient("file.txt") |
Create a container and upload a blob
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Azure.Identity;
// Connection with Entra ID (DefaultAzureCredential)
var serviceClient = new BlobServiceClient(
new Uri("https://<account>.blob.core.windows.net"),
new DefaultAzureCredential());
// Create the container (if it doesn't exist)
var containerClient = serviceClient.GetBlobContainerClient("images");
await containerClient.CreateIfNotExistsAsync(PublicAccessType.None);
// Upload a local file
var blobClient = containerClient.GetBlobClient("photo.jpg");
await using var fileStream = File.OpenRead(@"C:\temp\photo.jpg");
await blobClient.UploadAsync(fileStream, overwrite: true);
// Upload content from memory with Content-Type
var uploadOptions = new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = "image/jpeg" }
};
await blobClient.UploadAsync(BinaryData.FromString("text content"), uploadOptions);
Download a blob
// Download to a local file
await blobClient.DownloadToAsync(@"C:\temp\photo_downloaded.jpg");
// Download in memory (BinaryData)
var response = await blobClient.DownloadContentAsync();
string content = response.Value.Content.ToString();
// Download as Stream
var downloadInfo = await blobClient.DownloadStreamingAsync();
using var outputStream = File.OpenWrite(@"C:\temp\output.jpg");
await downloadInfo.Value.Content.CopyToAsync(outputStream);
List blobs
// List all blobs in a container (with metadata)
await foreach (var blobItem in containerClient.GetBlobsAsync(BlobTraits.Metadata))
{
Console.WriteLine($"Name: {blobItem.Name} | Size: {blobItem.Properties.ContentLength}");
foreach (var meta in blobItem.Metadata)
Console.WriteLine($" {meta.Key} = {meta.Value}");
}
// List by prefix (simulates a folder)
await foreach (var blobItem in containerClient.GetBlobsAsync(prefix: "folder/subfolder/"))
{
Console.WriteLine(blobItem.Name);
}
Delete a blob
// Simple deletion
await blobClient.DeleteIfExistsAsync();
// Deletion with snapshots
await blobClient.DeleteAsync(DeleteSnapshotsOption.IncludeSnapshots);
Metadata and properties
// Set custom metadata
var metadata = new Dictionary<string, string>
{
{ "category", "report" },
{ "department", "finance" }
};
await blobClient.SetMetadataAsync(metadata);
// Read properties and metadata
var props = await blobClient.GetPropertiesAsync();
Console.WriteLine($"Content-Type: {props.Value.ContentType}");
Console.WriteLine($"Tier: {props.Value.AccessTier}");
foreach (var kv in props.Value.Metadata)
Console.WriteLine($" {kv.Key} = {kv.Value}");
// Modify HTTP properties
await blobClient.SetHttpHeadersAsync(new BlobHttpHeaders
{
ContentType = "application/pdf",
CacheControl = "max-age=3600",
ContentDisposition = "attachment; filename=\"report.pdf\""
});
Leases (locks)
// Acquire a lease (exclusive write lock, 60s duration)
var leaseClient = blobClient.GetBlobLeaseClient();
var leaseResponse = await leaseClient.AcquireAsync(TimeSpan.FromSeconds(60));
string leaseId = leaseResponse.Value.LeaseId;
// Operation protected by the lease
var conditions = new BlobRequestConditions { LeaseId = leaseId };
await blobClient.SetMetadataAsync(metadata, conditions);
// Release the lease
await leaseClient.ReleaseAsync();
// Renew the lease before expiry
await leaseClient.RenewAsync();
SAS: User Delegation SAS vs Service SAS
| Criterion | User Delegation SAS | Service SAS |
|---|---|---|
| Signed by | User Delegation Key (obtained via Entra ID) | Storage Account Key |
| Recommended | ✅ Yes — does not require the account key | ⚠️ Less recommended |
| Revocable | Yes (revoke the User Delegation Key) | No (except key rotation) |
| Scope | Container or blob | Blob, container, file, queue, table |
| Prerequisites | Storage Blob Delegator role on the account | Account key available |
// ── User Delegation SAS ──────────────────────────────────────
var udKey = await serviceClient.GetUserDelegationKeyAsync(
startsOn: DateTimeOffset.UtcNow,
expiresOn: DateTimeOffset.UtcNow.AddHours(4));
var sasBuilder = new BlobSasBuilder
{
BlobContainerName = "images",
BlobName = "photo.jpg",
Resource = "b", // "b" = blob, "c" = container
StartsOn = DateTimeOffset.UtcNow,
ExpiresOn = DateTimeOffset.UtcNow.AddHours(4)
};
sasBuilder.SetPermissions(BlobSasPermissions.Read | BlobSasPermissions.Write);
var uriBuilder = new BlobUriBuilder(blobClient.Uri)
{
Sas = sasBuilder.ToSasQueryParameters(udKey, serviceClient.AccountName)
};
Uri sasUri = uriBuilder.ToUri(); // → distribute this temporary link
// ── Service SAS (with account key) ──────────────────────
var storageSharedKey = new StorageSharedKeyCredential("<account>", "<key>");
var serviceSasBuilder = new BlobSasBuilder
{
BlobContainerName = "images",
BlobName = "photo.jpg",
Resource = "b",
ExpiresOn = DateTimeOffset.UtcNow.AddHours(1)
};
serviceSasBuilder.SetPermissions(BlobSasPermissions.Read);
string sasToken = serviceSasBuilder.ToSasQueryParameters(storageSharedKey).ToString();
Lifecycle policy via SDK
using Azure.Storage.Blobs.Models;
using Azure.ResourceManager.Storage;
using Azure.ResourceManager.Storage.Models;
// Create a lifecycle policy via ARM (Azure Resource Manager)
var policy = new StorageAccountManagementPolicyData();
policy.Policy = new ManagementPolicySchema();
policy.Policy.Rules.Add(new ManagementPolicyRule("archive-old-blobs", ManagementPolicyRuleType.Lifecycle)
{
Definition = new ManagementPolicyDefinition
{
Actions = new ManagementPolicyAction
{
BaseBlob = new ManagementPolicyBaseBlob
{
TierToCool = new DateAfterModification { DaysAfterModificationGreaterThan = 30 },
TierToArchive = new DateAfterModification { DaysAfterModificationGreaterThan = 90 },
Delete = new DateAfterModification { DaysAfterModificationGreaterThan = 365 }
}
},
Filters = new ManagementPolicyFilter
{
BlobTypes = { "blockBlob" },
PrefixMatch = { "logs/" }
}
}
});
Module 6 – Azure Table Storage
Overview
Azure Table Storage is a NoSQL key-value storage service for large amounts of semi-structured data. It is part of the GPv2 storage account and accessible via the Azure.Data.Tables SDK.
graph LR
A[Storage account] --> B[Table]
B --> C[Entity\nPartitionKey + RowKey + properties]
| Concept | Description |
|---|---|
| Table | Collection of entities (equivalent to a SQL table, but without a fixed schema) |
| Entity | Row of data. Max 1 MB. Up to 252 custom properties + 3 system |
| PartitionKey | Partitioning key — groups entities in the same physical partition |
| RowKey | Unique identifier within a partition — lexicographic sort |
| ETag | Entity version for optimistic conflict management |
TableServiceClient and TableClient
using Azure.Data.Tables;
using Azure.Identity;
// Connection with Entra ID
var serviceClient = new TableServiceClient(
new Uri("https://<account>.table.core.windows.net"),
new DefaultAzureCredential());
// Create a table
await serviceClient.CreateTableIfNotExistsAsync("customers");
// Get a client on an existing table
var tableClient = serviceClient.GetTableClient("customers");
Entity model
// Implements ITableEntity (or inherits from TableEntity)
public class CustomerEntity : ITableEntity
{
public string PartitionKey { get; set; } // e.g.: country code "US"
public string RowKey { get; set; } // e.g.: customer identifier "C001"
public string Name { get; set; }
public string Email { get; set; }
public DateTimeOffset? Timestamp { get; set; }
public ETag ETag { get; set; }
}
Complete CRUD
var entity = new CustomerEntity
{
PartitionKey = "US",
RowKey = "C001",
Name = "Acme Corp",
Email = "contact@acme.com"
};
// CREATE
await tableClient.AddEntityAsync(entity);
// READ (point read — very performant)
var result = await tableClient.GetEntityAsync<CustomerEntity>("US", "C001");
Console.WriteLine(result.Value.Name);
// UPDATE (Merge = only sent properties are updated)
entity.Email = "newmail@acme.com";
await tableClient.UpdateEntityAsync(entity, entity.ETag, TableUpdateMode.Merge);
// REPLACE (overwrites all properties)
await tableClient.UpdateEntityAsync(entity, ETag.All, TableUpdateMode.Replace);
// UPSERT (creates or replaces based on existence)
await tableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);
// DELETE
await tableClient.DeleteEntityAsync("US", "C001");
Filters and pagination with continuation tokens
// Simple OData filter
string filter = TableClient.CreateQueryFilter<CustomerEntity>(
e => e.PartitionKey == "US" && e.Name == "Acme Corp");
// Paginated query (max 1000 entities per page)
string? continuationToken = null;
do
{
var page = tableClient
.QueryAsync<CustomerEntity>(filter: filter, maxPerPage: 100)
.AsPages(continuationToken);
await foreach (var tablePage in page)
{
foreach (var e in tablePage.Values)
Console.WriteLine($"{e.PartitionKey}/{e.RowKey}: {e.Name}");
continuationToken = tablePage.ContinuationToken;
}
} while (continuationToken != null);
// Query by PartitionKey only (within a single partition → performant)
await foreach (var e in tableClient.QueryAsync<CustomerEntity>(
e => e.PartitionKey == "US"))
{
Console.WriteLine(e.Name);
}
Batch operations (TransactionalBatch)
Batch operations must share the same PartitionKey (Table Storage limitation).
// Batch: all entities must have the same PartitionKey
var batch = new List<TableTransactionAction>
{
new TableTransactionAction(TableTransactionActionType.Add, new CustomerEntity
{ PartitionKey = "EU", RowKey = "C010", Name = "TechBerlin" }),
new TableTransactionAction(TableTransactionActionType.Add, new CustomerEntity
{ PartitionKey = "EU", RowKey = "C011", Name = "CloudParis" }),
new TableTransactionAction(TableTransactionActionType.Delete,
new CustomerEntity { PartitionKey = "EU", RowKey = "C009", ETag = ETag.All })
};
// Max 100 operations per batch, max 4 MB
var responses = await tableClient.SubmitTransactionAsync(batch);
foreach (var r in responses.Value)
Console.WriteLine($"Status: {r.Status}");
PartitionKey / RowKey design
flowchart TD
A[Goal: minimize cross-partition queries] --> B{What access\nis most frequent?}
B -->|By unique entity| C[PartitionKey = parent entity\nRowKey = unique ID]
B -->|By date/range| D[PartitionKey = YYYY-MM\nRowKey = inverted timestamp\nnnnnnnnnn - ticks]
B -->|By category| E[PartitionKey = category\nRowKey = GUID]
| Best practice | Explanation |
|---|---|
| Avoid a single global PartitionKey | Creates a hot partition, limits throughput |
| Avoid too many tiny partitions | Hurts scans that cross multiple partitions |
| Inverted RowKey | string.MaxValue - DateTime.UtcNow.Ticks → most recent entries first |
| Limit entity size | Max 1 MB, ideally < 50 KB |
Module 7 – Azure Cosmos DB SDK (deep dive)
CosmosClient — single instance (Singleton)
AZ-204 golden rule:
CosmosClientis expensive to create (establishes persistent TCP connections). It must be instantiated only once and reused (dependency injection → Singleton).
// Program.cs — Singleton registration for ASP.NET Core / Azure Functions
builder.Services.AddSingleton(_ => new CosmosClient(
accountEndpoint: builder.Configuration["CosmosDb:Endpoint"],
tokenCredential: new DefaultAzureCredential(),
clientOptions: new CosmosClientOptions
{
ApplicationName = "MyApp",
ConnectionMode = ConnectionMode.Direct, // Direct = lower latency
SerializerOptions = new CosmosSerializationOptions
{
PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
}
}));
Create a database and container
// Get or create the database
Database db = await cosmosClient.CreateDatabaseIfNotExistsAsync("ecommerce");
// Create a container with partition key and throughput
ContainerProperties props = new ContainerProperties
{
Id = "products",
PartitionKeyPath = "/category" // must start with "/"
};
Container container = await db.CreateContainerIfNotExistsAsync(
containerProperties: props,
throughput: 400); // 400 RU/s minimum
Complete CRUD
public record Product(string id, string category, string name, decimal price);
// CREATE
var product = new Product("p-001", "electronics", "Mechanical keyboard", 149.99m);
ItemResponse<Product> createResp = await container.CreateItemAsync(
product, new PartitionKey(product.category));
Console.WriteLine($"RUs consumed: {createResp.RequestCharge}");
// READ — point read (1 RU for 1 KB, very performant)
ItemResponse<Product> readResp = await container.ReadItemAsync<Product>(
id: "p-001", partitionKey: new PartitionKey("electronics"));
Product p = readResp.Resource;
// REPLACE (replaces the entire document)
var updated = p with { price = 129.99m };
await container.ReplaceItemAsync(updated, updated.id, new PartitionKey(updated.category));
// UPSERT (creates or replaces)
await container.UpsertItemAsync(updated, new PartitionKey(updated.category));
// PATCH (partial modification — .NET SDK v3.23+)
await container.PatchItemAsync<Product>(
id: "p-001",
partitionKey: new PartitionKey("electronics"),
patchOperations: new[] { PatchOperation.Replace("/price", 119.99m) });
// DELETE
await container.DeleteItemAsync<Product>(
id: "p-001", partitionKey: new PartitionKey("electronics"));
Point reads vs Queries
| Criterion | Point Read (ReadItemAsync) | Query (GetItemQueryIterator) |
|---|---|---|
| Cost | ~1 RU (1 KB) | Variable depending on complexity |
| Requires | id + PartitionKey | Parameterized SQL query |
| Performance | O(1) — direct access | Possible scan if no PartitionKey |
| Usage | Retrieve a known item | Filter, aggregate, list |
Parameterized queries (QueryDefinition)
// Parameterized query — prevents injection and improves performance
var query = new QueryDefinition(
"SELECT * FROM c WHERE c.category = @cat AND c.price < @maxPrice")
.WithParameter("@cat", "electronics")
.WithParameter("@maxPrice", 200.0);
using FeedIterator<Product> iterator = container.GetItemQueryIterator<Product>(
query,
requestOptions: new QueryRequestOptions
{
PartitionKey = new PartitionKey("electronics"), // avoids cross-partition
MaxItemCount = 50 // page size
});
while (iterator.HasMoreResults)
{
FeedResponse<Product> page = await iterator.ReadNextAsync();
Console.WriteLine($"Page RUs: {page.RequestCharge}");
foreach (var item in page)
Console.WriteLine($"{item.name} — {item.price:C}");
}
Change Feed Processor
// Build and start the processor
ChangeFeedProcessor processor = container
.GetChangeFeedProcessorBuilder<Product>(
processorName: "syncPrices",
onChangesDelegate: async (changes, cancellationToken) =>
{
foreach (var product in changes)
Console.WriteLine($"Change detected: {product.name}");
})
.WithInstanceName("instance-1")
.WithLeaseContainer(leaseContainer) // separate leases container
.WithStartTime(DateTime.UtcNow) // optional: start at a specific time
.Build();
await processor.StartAsync();
// ... background processing ...
await processor.StopAsync();
Module 8 – Cosmos DB: Partitioning
Why partition key choice is critical
flowchart LR
A[Item written] --> B{Hash of\nPartitionKey}
B --> C[Physical partition 1\nmax ~50 GB / ~10000 RU/s]
B --> D[Physical partition 2]
B --> E[Physical partition N]
- Cosmos DB distributes items between physical partitions via a hash of the PartitionKey.
- Each physical partition can handle up to ~10,000 RU/s and store ~50 GB.
- A bad partition key can create a hot partition (bottleneck).
Selection criteria
| Criterion | Recommendation |
|---|---|
| High cardinality | Prefer a property with many distinct values (e.g.: userId, orderId) |
| Uniform distribution | Each value should represent approximately the same volume of traffic/data |
| Included in frequent queries | Avoids costly cross-partition queries |
| Immutable | The PK cannot be changed after item creation |
| Avoid overly generic values | "active"/"inactive" → hot partition almost certain |
Cross-Partition Queries
// ❌ Cross-partition (expensive — scans all partitions)
var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @name")
.WithParameter("@name", "Mechanical keyboard");
// No PartitionKey in QueryRequestOptions → Cosmos DB scans everything
// ✅ Single-partition (performant)
var query = new QueryDefinition("SELECT * FROM c WHERE c.category = @cat AND c.name = @name")
.WithParameter("@cat", "electronics")
.WithParameter("@name", "Mechanical keyboard");
var opts = new QueryRequestOptions { PartitionKey = new PartitionKey("electronics") };
Hot Partitions — Detection and remedies
| Symptom | Probable cause | Solution |
|---|---|---|
| 429 errors (TooManyRequests) on certain partitions | Low cardinality PartitionKey | Change the PK or add a random suffix |
| Overall throughput underutilized but one partition saturated | Very dominant PK value (e.g.: "admin") | Synthetic partition key |
| High latency on a subset of items | Too popular key | Hierarchical partition keys |
Synthetic Partition Keys
// Problem: orderId = GUID, but all orders from a customer go to the same place
// Solution: combine multiple attributes to create a synthetic key
public record Order
{
public string id { get; init; }
public string customerId { get; init; }
public string region { get; init; }
// Synthetic key: distributes evenly AND allows queries by customer+region
public string pk => $"{customerId}_{region}";
}
// Or add a numeric suffix for high-throughput containers
string pk = $"{userId}_{new Random().Next(1, 10)}"; // distributes across 10 logical partitions
Hierarchical Partition Keys (SDK v3.23+)
// Up to 3 levels of partition key (preview → GA)
ContainerProperties props = new ContainerProperties("orders", "/tenantId")
{
PartitionKeyDefinition = new PartitionKeyDefinition
{
Paths = { "/tenantId", "/customerId", "/orderId" },
Version = PartitionKeyDefinitionVersion.V2,
Kind = PartitionKind.MultiHash
}
};
// Query on the first level only (efficient)
var opts = new QueryRequestOptions
{
PartitionKey = new PartitionKeyBuilder()
.Add("tenant-A")
.Build()
};
Module 9 – Azure Cache for Redis SDK
Architecture and use cases
graph LR
App[Application\nASP.NET Core] -->|IConnectionMultiplexer| Redis["(Azure Cache\nfor Redis)"]
Redis -->|StringGet/HashGet/etc.| App
App -->|Cache-aside pattern| DB["(Database\nSQL / Cosmos DB)"]
| Use case | Description |
|---|---|
| Session cache | Store user session state (faster than database) |
| Data cache | Reduce repetitive reads on rarely changing data |
| Rate limiting | Count requests by IP with automatic expiration |
| Distributed lock | Coordinate access to a shared resource between instances |
| Pub/Sub | Asynchronous communication between microservices |
| Leaderboard | Sorted sets for real-time rankings |
Connection with IConnectionMultiplexer
using StackExchange.Redis;
// IConnectionMultiplexer must be a SINGLETON (reuse TCP connections)
// Program.cs
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect(new ConfigurationOptions
{
EndPoints = { "my-cache.redis.cache.windows.net:6380" },
Password = builder.Configuration["Redis:Password"],
Ssl = true,
AbortOnConnectFail = false, // attempt to reconnect if connection fails
ConnectRetry = 3
}));
// In the service
public class CacheService
{
private readonly IDatabase _db;
public CacheService(IConnectionMultiplexer mux) => _db = mux.GetDatabase();
}
String operations
// SET with expiration (TTL)
await _db.StringSetAsync("user:42:name", "Alice Johnson", TimeSpan.FromMinutes(30));
// GET
RedisValue value = await _db.StringGetAsync("user:42:name");
if (value.HasValue)
Console.WriteLine(value.ToString());
// INCR (atomic counter)
long visitCount = await _db.StringIncrementAsync("stats:page:home");
// SETNX (Set if Not eXists) — basis of distributed lock
bool locked = await _db.StringSetAsync(
"lock:critical-resource",
Environment.MachineName,
TimeSpan.FromSeconds(30),
When.NotExists); // ← SETNX
Hash operations (structured object)
// Store an object as a hash
await _db.HashSetAsync("product:p-001", new HashEntry[]
{
new HashEntry("name", "Mechanical keyboard"),
new HashEntry("price", "149.99"),
new HashEntry("stock", "42")
});
// Read a field
string name = await _db.HashGetAsync("product:p-001", "name");
// Read the entire hash
HashEntry[] fields = await _db.HashGetAllAsync("product:p-001");
// Increment a numeric field in a hash
await _db.HashIncrementAsync("product:p-001", "stock", -1);
Lists, Sets and Sorted Sets
// ── List (FIFO queue / stack) ────────────────────────────────
await _db.ListRightPushAsync("queue:emails", "email-001"); // enqueue
RedisValue email = await _db.ListLeftPopAsync("queue:emails"); // dequeue
// ── Set (unordered set, unique values) ─────────────
await _db.SetAddAsync("tags:article:123", new RedisValue[] { "azure", "cloud", "storage" });
bool contains = await _db.SetContainsAsync("tags:article:123", "azure");
RedisValue[] tags = await _db.SetMembersAsync("tags:article:123");
// ── Sorted Set (leaderboard / ranking) ───────────────────────
// ZADD score member
await _db.SortedSetAddAsync("leaderboard:game", new SortedSetEntry[]
{
new SortedSetEntry("Alice", 1500),
new SortedSetEntry("Bob", 1200),
new SortedSetEntry("Carol", 1800)
});
// Top 3
SortedSetEntry[] top3 = await _db.SortedSetRangeByRankWithScoresAsync(
"leaderboard:game", 0, 2, Order.Descending);
Expiration and eviction
// Set/modify TTL of an existing key
await _db.KeyExpireAsync("session:user-42", TimeSpan.FromHours(2));
// Check remaining TTL
TimeSpan? ttl = await _db.KeyTimeToLiveAsync("session:user-42");
// Delete a key
await _db.KeyDeleteAsync("session:user-42");
Pub/Sub
// Publisher
var pub = mux.GetSubscriber();
await pub.PublishAsync("channel:orders", JsonSerializer.Serialize(order));
// Subscriber — in another service/instance
var sub = mux.GetSubscriber();
await sub.SubscribeAsync("channel:orders", (channel, message) =>
{
var cmd = JsonSerializer.Deserialize<Order>(message);
Console.WriteLine($"New order received: {cmd.Id}");
});
Distributed Lock with SETNX
// Pattern: acquire → try → finally release
string lockKey = "lock:batch-processing";
string lockValue = Guid.NewGuid().ToString(); // unique value per instance
TimeSpan ttl = TimeSpan.FromSeconds(30);
bool acquired = await _db.StringSetAsync(lockKey, lockValue, ttl, When.NotExists);
if (acquired)
{
try
{
// Critical section — only one instance can enter here
await ProcessBatchAsync();
}
finally
{
// Release only if the key still belongs to this instance (Lua script)
const string releaseLua = @"
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else return 0 end";
await _db.ScriptEvaluateAsync(releaseLua,
new RedisKey[] { lockKey },
new RedisValue[] { lockValue });
}
}
Module 10 – Azure SQL Database SDK
SqlConnection / SqlCommand / SqlDataReader (ADO.NET)
using Microsoft.Data.SqlClient;
string connStr = builder.Configuration.GetConnectionString("AzureSQL");
// Parameterized query (NEVER concatenate → SQL injection)
await using var connection = new SqlConnection(connStr);
await connection.OpenAsync();
var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT Id, Name, Email FROM Customers WHERE Region = @region";
cmd.Parameters.AddWithValue("@region", "WA");
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
int id = reader.GetInt32(0);
string name = reader.GetString(1);
string email = reader.GetString(2);
Console.WriteLine($"{id} | {name} | {email}");
}
// INSERT with ExecuteNonQueryAsync
var insert = connection.CreateCommand();
insert.CommandText = "INSERT INTO Customers (Name, Email, Region) VALUES (@name, @email, @region)";
insert.Parameters.AddWithValue("@name", "Acme Corp");
insert.Parameters.AddWithValue("@email", "info@acme.com");
insert.Parameters.AddWithValue("@region", "WA");
int rowsAffected = await insert.ExecuteNonQueryAsync();
// Scalar
var scalar = connection.CreateCommand();
scalar.CommandText = "SELECT COUNT(*) FROM Customers WHERE Region = @r";
scalar.Parameters.AddWithValue("@r", "WA");
int total = (int)(await scalar.ExecuteScalarAsync())!;
Entity Framework Core with Azure SQL
// AppDbContext
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Customer> Customers { get; set; }
public DbSet<Order> Orders { get; set; }
protected override void OnModelCreating(ModelBuilder mb)
{
mb.Entity<Customer>().HasIndex(c => c.Email).IsUnique();
mb.Entity<Order>()
.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId);
}
}
// Program.cs — registration with resilience retry
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("AzureSQL"),
sqlOptions => sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null))); // null = default transient strategy
// Usage in a service
public class CustomerService(AppDbContext db)
{
public async Task<List<Customer>> GetByRegionAsync(string region) =>
await db.Customers
.Where(c => c.Region == region)
.OrderBy(c => c.Name)
.AsNoTracking() // read-only → no tracking → more performant
.ToListAsync();
public async Task AddAsync(Customer customer)
{
db.Customers.Add(customer);
await db.SaveChangesAsync();
}
}
Retry Logic and connection resilience
Azure SQL can return transient errors (network, failover, throttling). EF Core and ADO.NET support retry strategies:
// ADO.NET — manual retry with Polly
using Polly;
using Polly.Retry;
AsyncRetryPolicy retryPolicy = Policy
.Handle<SqlException>(ex => IsTransient(ex))
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)),
onRetry: (ex, delay, attempt, _) =>
Console.WriteLine($"Attempt {attempt} after {delay}: {ex.Message}"));
await retryPolicy.ExecuteAsync(async () =>
{
await using var conn = new SqlConnection(connStr);
await conn.OpenAsync();
// ... SQL commands ...
});
// Detection of transient Azure SQL errors
static bool IsTransient(SqlException ex) =>
ex.Number is 4060 or 40197 or 40501 or 40613 or 49918 or 49919 or 49920 or 1205;
Connection Pooling
Azure SQL (and SQL Server) uses connection pooling by default with ADO.NET:
| Connection string parameter | Default value | Role |
|---|---|---|
Min Pool Size | 0 | Minimum maintained connections |
Max Pool Size | 100 | Max simultaneous connections in the pool |
Connect Timeout | 30 s | Delay before error if no connection available |
Pooling | true | Enable/disable pooling |
// Optimized connection string for Azure SQL
Server=tcp:<server>.database.windows.net,1433;
Initial Catalog=<db>;
Encrypt=True;
TrustServerCertificate=False;
Connection Timeout=30;
Min Pool Size=5;
Max Pool Size=50;
Authentication=Active Directory Default; // ← Entra ID (Managed Identity)
Authentication with Managed Identity (without password)
// EF Core with Entra ID / Managed Identity (no password in config)
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("AzureSQL"),
sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(5);
})
// Inject an Entra ID token on each connection
.AddInterceptors(new AadAuthenticationDbConnectionInterceptor()));
// Custom interceptor
public class AadAuthenticationDbConnectionInterceptor : DbConnectionInterceptor
{
private static readonly TokenCredential _credential = new DefaultAzureCredential();
public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
DbConnection connection, ConnectionEventData eventData,
InterceptionResult result, CancellationToken ct)
{
var sqlConn = (SqlConnection)connection;
var token = await _credential.GetTokenAsync(
new TokenRequestContext(new[] { "https://database.windows.net/.default" }), ct);
sqlConn.AccessToken = token.Token;
return result;
}
}
Module 11 – Blob Storage Lifecycle Management (JSON and advanced rules)
JSON structure of a lifecycle policy
{
"rules": [
{
"name": "tierTransition",
"enabled": true,
"type": "Lifecycle",
"definition": {
"actions": {
"baseBlob": {
"tierToCool": {
"daysAfterModificationGreaterThan": 30
},
"tierToCold": {
"daysAfterModificationGreaterThan": 60
},
"tierToArchive": {
"daysAfterLastAccessTimeGreaterThan": 90
},
"delete": {
"daysAfterModificationGreaterThan": 365
}
},
"snapshot": {
"delete": {
"daysAfterCreationGreaterThan": 90
}
},
"version": {
"tierToArchive": {
"daysAfterCreationGreaterThan": 30
},
"delete": {
"daysAfterCreationGreaterThan": 365
}
}
},
"filters": {
"blobTypes": ["blockBlob"],
"prefixMatch": ["logs/", "backups/2023"],
"blobIndexMatch": [
{
"name": "project",
"op": "==",
"value": "migration-2023"
}
]
}
}
}
]
}
Tier transitions and available conditions
graph LR
Hot -->|daysAfterModificationGreaterThan ≥ 30| Cool
Cool -->|daysAfterModificationGreaterThan ≥ 60| Cold
Cold -->|daysAfterLastAccessTimeGreaterThan ≥ 90| Archive
Archive -->|manual rehydration| Hot
Archive -->|manual rehydration| Cool
Hot -->|delete| Deleted
Cool -->|delete| Deleted
Cold -->|delete| Deleted
Archive -->|delete| Deleted
| Condition | Applies to | Description |
|---|---|---|
daysAfterModificationGreaterThan | baseBlob, snapshot, version | Days since last modification |
daysAfterLastAccessTimeGreaterThan | baseBlob | Days since last access (requires Last Access Time Tracking) |
daysAfterCreationGreaterThan | snapshot, version | Days since creation |
daysAfterLastTierChangeGreaterThan | baseBlob | Days since last tier change |
prefixMatch | filters | Blob name prefix (container + path) |
blobIndexMatch | filters | Blob index tag (key + operator + value) |
blobTypes | filters | blockBlob and/or appendBlob |
Deploy a policy via Azure CLI
# Apply the policy from a JSON file
az storage account management-policy create \
--account-name <account> \
--resource-group <rg> \
--policy @lifecycle-policy.json
# Display the current policy
az storage account management-policy show \
--account-name <account> \
--resource-group <rg>
Rehydration from Archive
// Rehydrate a blob from Archive (can take 1 to 15 hours)
// Priority: High (~1h) or Standard (~15h)
var blobClient = containerClient.GetBlobClient("backup-2023.zip");
await blobClient.SetAccessTierAsync(
AccessTier.Cool,
rehydratePriority: RehydratePriority.High);
// Check the rehydration status
var props = await blobClient.GetPropertiesAsync();
Console.WriteLine($"Tier: {props.Value.AccessTier}");
Console.WriteLine($"Archive status: {props.Value.ArchiveStatus}");
// ArchiveStatus: "rehydrate-pending-to-hot" or "rehydrate-pending-to-cool"
Module 12 – Azure CDN (Content Delivery Network)
CDN Architecture
graph LR
User[User\nSeattle] -->|1. Request| POP[Point of Presence\nSeattle CDN Edge]
POP -->|Cache HIT| User
POP -->|2. Cache MISS: origin| Origin[Origin\nBlob Storage / App Service]
Origin -->|3. Content| POP
POP -->|4. Cached + delivered| User
- CDN Profile: Azure resource that groups CDN endpoints.
- CDN Endpoint: public CDN URL (
<name>.azureedge.net) + cache and origin configuration. - Origin: content source (Blob Storage, App Service, custom IP/FQDN).
- POP (Point of Presence): geographically distributed edge nodes that cache content.
CDN providers available on Azure
| Provider | Tier | Key features |
|---|---|---|
| Azure CDN Standard from Microsoft | Standard | Delivery rules, compression, HTTPS, custom domain |
| Azure CDN Standard from Edgio | Standard | Advanced rules, cache control |
| Azure CDN Premium from Edgio | Premium | Advanced rules, analytics, optimization |
Create a profile and endpoint (Azure CLI)
# Create the CDN profile
az cdn profile create \
--name cdn-myapp-prod \
--resource-group rg-myapp \
--sku Standard_Microsoft
# Create an endpoint pointing to Blob Storage
az cdn endpoint create \
--name myapp-assets \
--profile-name cdn-myapp-prod \
--resource-group rg-myapp \
--origin <account>.blob.core.windows.net \
--origin-host-header <account>.blob.core.windows.net \
--enable-compression true \
--content-types-to-compress "text/css" "application/javascript" "image/svg+xml"
Caching rules
| Behavior | Description |
|---|---|
| Honor origin (default) | Respects Cache-Control and Expires headers from the origin |
| Override | Replaces origin headers with a specific duration |
| Bypass | Never uses cache for this content |
| Set if missing | Applies a duration only if the origin sends no cache header |
Query String Caching
| Mode | Behavior |
|---|---|
IgnoreQueryString | image.png?v=1 and image.png?v=2 → same cache entry |
UseQueryString | Each query string combination = separate cache entry |
BypassCaching | Requests with query strings are never cached |
Purge (cache invalidation)
# Purge a specific path
az cdn endpoint purge \
--name myapp-assets \
--profile-name cdn-myapp-prod \
--resource-group rg-myapp \
--content-paths "/images/logo.png" "/css/styles.css"
# Purge the entire cache (wildcard)
az cdn endpoint purge \
--name myapp-assets \
--profile-name cdn-myapp-prod \
--resource-group rg-myapp \
--content-paths "/*"
// Purge via C# SDK
using Azure.ResourceManager.Cdn;
var endpoint = await cdnClient.GetCdnEndpointAsync(endpointName);
await endpoint.Value.PurgeContentAsync(WaitUntil.Completed,
new PurgeParameters(new[] { "/images/*", "/css/styles.css" }));
Custom domain + HTTPS
# 1. Create a CNAME DNS: mysite.com → myapp-assets.azureedge.net
# 2. Add the custom domain to the endpoint
az cdn custom-domain create \
--endpoint-name myapp-assets \
--profile-name cdn-myapp-prod \
--resource-group rg-myapp \
--name mysite \
--hostname "cdn.mysite.com"
# 3. Enable HTTPS (certificate managed by Azure — free)
az cdn custom-domain enable-https \
--endpoint-name myapp-assets \
--profile-name cdn-myapp-prod \
--resource-group rg-myapp \
--name mysite
Delivery Rules — examples
{
"name": "RedirectHTTPtoHTTPS",
"order": 1,
"conditions": [
{ "name": "RequestScheme", "parameters": { "matchValues": ["HTTP"] } }
],
"actions": [
{ "name": "UrlRedirect", "parameters": { "redirectType": "PermanentRedirect", "destinationProtocol": "Https" } }
]
}
Module 13 – Static Website Hosting
Enable static website hosting
# Enable via Azure CLI
az storage blob service-properties update \
--account-name <account> \
--static-website \
--index-document "index.html" \
--404-document "404.html"
# Check the static website URL
az storage account show \
--name <account> \
--query "primaryEndpoints.web" \
--output tsv
# → https://<account>.z9.web.core.windows.net/
Enabling automatically creates a special container $web in the storage account. Files uploaded to $web are served as a website.
// Upload build files to $web (e.g.: React/Angular/Vue)
var containerClient = serviceClient.GetBlobContainerClient("$web");
foreach (var file in Directory.GetFiles("./dist", "*", SearchOption.AllDirectories))
{
string blobName = file.Replace("./dist/", "").Replace("\\", "/");
var blobClient = containerClient.GetBlobClient(blobName);
string contentType = Path.GetExtension(file) switch
{
".html" => "text/html",
".css" => "text/css",
".js" => "application/javascript",
".png" => "image/png",
".svg" => "image/svg+xml",
".json" => "application/json",
_ => "application/octet-stream"
};
await blobClient.UploadAsync(File.OpenRead(file), new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = contentType }
}, overwrite: true);
}
URL structure and endpoints
| Endpoint | Format | Usage |
|---|---|---|
| Web endpoint (static website) | https://<account>.z<N>.web.core.windows.net/ | Serves index.html at root, custom 404 |
| Blob endpoint | https://<account>.blob.core.windows.net/$web/ | Direct blob access (doesn’t serve index by default) |
| CDN endpoint | https://<name>.azureedge.net/ | After CDN integration |
CDN integration for a static website
graph LR
User[User] --> CDN[Azure CDN\nazureedge.net]
CDN -->|Cache MISS| StaticWeb[Static Website\n.z9.web.core.windows.net]
StaticWeb --> BlobStorage[Container $web\nBlob Storage]
# Origin = the web endpoint (NOT the blob endpoint)
az cdn endpoint create \
--name mysite-cdn \
--profile-name cdn-mysite \
--resource-group rg-mysite \
--origin <account>.z9.web.core.windows.net \
--origin-host-header <account>.z9.web.core.windows.net
Important: use the web endpoint (
.z9.web.core.windows.net) as CDN origin, not the blob endpoint. This allows the CDN to correctly handleindex.htmland 404 documents.
Custom domain for static website
# Option 1: Via CDN (recommended — native HTTPS support)
# 1. Create CNAME: www.mysite.com → mysite-cdn.azureedge.net
# 2. Add custom domain on the CDN endpoint
# 3. Enable Azure-managed HTTPS
# Option 2: Directly on the storage account (HTTP only)
az storage account update \
--name <account> \
--resource-group rg-mysite \
--custom-domain "www.mysite.com" \
--use-subdomain false
SPA Routing Workaround
Single Page Applications (React, Angular, Vue) handle routing client-side. The problem: if a user directly accesses https://mysite.com/products/42, the server looks for a products/42/index.html file that doesn’t exist → 404.
Solution: configure the 404 document = index.html to return the app even on unknown routes.
# SPA configuration: 404 points to index.html
az storage blob service-properties update \
--account-name <account> \
--static-website \
--index-document "index.html" \
--404-document "index.html" # ← SPA workaround
sequenceDiagram
participant U as User
participant CDN
participant Storage as Blob Storage ($web)
U->>CDN: GET /products/42
CDN->>Storage: /products/42 (not found)
Storage-->>CDN: 404 → index.html
CDN-->>U: index.html (200)
Note over U: React Router handles\n/products/42 client-side
Exam note: The HTTP code returned will be 404 but with the content of
index.html. Some CDNs can be configured to return a 200 with the same content — check per provider.
Module 14 – AZ-204 Review Questions
Q1 — Blob Storage SDK
Your application needs to read a file report.pdf in the documents container of a storage account. What is the correct class sequence?
- A)
BlobServiceClient→BlobClient→BlobContainerClient - B)
BlobContainerClient→BlobServiceClient→BlobClient - C)
BlobServiceClient→BlobContainerClient→BlobClient✅ - D)
BlobClient→BlobContainerClient→BlobServiceClient
Answer: C — The hierarchy is always Service → Container → Blob.
Q2 — User Delegation SAS vs Service SAS
Which of the following statements is TRUE about User Delegation SAS?
- A) It is signed by the storage account key.
- B) It can be revoked without having to change the account key. ✅
- C) It requires the
Storage Blob Data Ownerrole to be generated. - D) It only allows read access.
Answer: B — The User Delegation SAS is signed by a User Delegation Key obtained via Entra ID. Revoking this key invalidates all associated SAS tokens, without touching the account key.
Q3 — Azure Table Storage
You need to store millions of application logs, each log having an appId and a timestamp. What PartitionKey/RowKey combination is most suitable?
- A) PartitionKey =
"logs", RowKey = random GUID - B) PartitionKey =
appId, RowKey =inverted timestamp✅ - C) PartitionKey =
timestamp, RowKey =appId - D) PartitionKey = random GUID, RowKey =
appId
Answer: B —
PartitionKey = appIdallows retrieving all logs from an application in a single partition (efficient query).RowKey = inverted timestampsorts logs from most recent to oldest.
Q4 — Cosmos DB Singleton
Why should CosmosClient be instantiated as a Singleton in an ASP.NET Core application?
- A) Because Cosmos DB does not support multiple simultaneous connections.
- B) To avoid data duplication in multiple containers.
- C) Because it maintains persistent TCP connections and is expensive to create. ✅
- D) To share the same RU/s budget across all instances.
Answer: C —
CosmosClientestablishes persistent TCP connections at startup. Creating multiple instances leads to resource overconsumption and may exhaust available connections.
Q5 — Point Read vs Query
You are retrieving a Cosmos DB document for which you know the id ("prod-42") and the PartitionKey ("electronics"). What method should you use?
- A)
GetItemQueryIterator<T>withSELECT * FROM c WHERE c.id = 'prod-42' - B)
ReadItemAsync<T>("prod-42", new PartitionKey("electronics"))✅ - C)
UpsertItemAsync<T>then check the result - D)
ReplaceItemAsync<T>with the last known version
Answer: B — A point read is the most performant operation (~1 RU for 1 KB) because it directly accesses the item without scanning.
Q6 — Change Feed
Your Cosmos DB container holds orders. You want to update a microservice every time an order is deleted. How should you proceed?
- A) Use the Change Feed in
Latest Versionmode — it captures deletions by default. - B) Implement soft delete: add a
deleted = truefield + TTL, and process this change via Change Feed. ✅ - C) Use an Azure SQL trigger that synchronizes Cosmos DB.
- D) Query the container every 30 seconds with a last-modification timestamp.
Answer: B — By default, the Change Feed does not capture deletions. The recommended practice is soft delete (mark as deleted + TTL). The
All Versions and Deletesmode (preview) can also be used.
Q7 — Redis Distributed Lock
Your Azure application has multiple instances. You need to guarantee that only one batch processing runs at a time. What Redis command should you use?
- A)
StringSetAsync(key, value)without condition - B)
ListRightPushAsync(key, value)thenListLeftPopAsync - C)
StringSetAsync(key, value, ttl, When.NotExists)✅ - D)
HashSetAsync(key, field, value)
Answer: C —
SETNX(SET if Not eXists) is the basis of the Redis distributed lock. If the key already exists, the operation fails atomically, guaranteeing that only one instance acquires the lock.
Q8 — Azure SQL Retry
Your Azure application receives SqlException number 40613 errors when connecting to Azure SQL. What does this error represent and how should you handle it?
- A) SQL syntax error — fix the query.
- B) DTU quota exceeded — increase the service tier.
- C) Transient error (database temporarily unavailable) — implement a retry strategy with exponential backoff. ✅
- D) Permission error — add the
db_datareaderrole.
Answer: C — Error
40613means the database is temporarily unavailable (failover, maintenance). It is transient and must be handled with retry + backoff. EF Core withEnableRetryOnFailure()handles it automatically.
Q9 — Lifecycle Management
A lifecycle policy is configured with "daysAfterLastAccessTimeGreaterThan": 90 to move blobs to Archive. The rule does not seem to apply. What is the probable cause?
- A) Blobs are of type
appendBlob— lifecycle applies only toblockBlob. - B) Last Access Time Tracking is not enabled on the storage account. ✅
- C) The condition cannot be used in a lifecycle policy.
- D) Blobs are in the Cold tier — direct Cold → Archive transition is not supported.
Answer: B — The
daysAfterLastAccessTimeGreaterThancondition requires Last Access Time Tracking to be enabled in the storage account settings. Without this, Azure does not know the last access date.
Q10 — CDN Query String
Your CDN endpoint is configured with IgnoreQueryString. Your team deploys a new version of the app.js file and adds ?v=2 to the URL. Users still receive the old version. Why?
- A) CDN does not support JavaScript files.
- B)
IgnoreQueryStringtreatsapp.js?v=1andapp.js?v=2as the same cache entry. ✅ - C) Compression must be enabled to invalidate the cache.
- D) The custom domain has not yet propagated in DNS.
Answer: B — With
IgnoreQueryString, query strings are ignored for caching.app.js?v=1andapp.js?v=2point to the same cache entry. Solution: switch toUseQueryStringmode or perform a manual purge.
Q11 — Static Website SPA Routing
Your React application is hosted in Azure Static Website. Users report a 404 error when directly accessing https://myapp.com/dashboard. What will resolve this problem?
- A) Manually create a
dashboard/index.htmlfile in$web. - B) Configure the
--404-documenttoindex.html. ✅ - C) Enable CDN with
BypassCachingmode. - D) Change the blob type from
blockBlobtoappendBlob.
Answer: B — The SPA routing workaround is to point the 404 document to
index.html. When an unknown route is requested, Azure Storage returnsindex.htmlwhich allows React Router to handle client-side navigation.
Q12 — Cosmos DB Partition Key
You are designing a Cosmos DB container to store chat messages. The application has 50,000 active users sending messages. What is the best PartitionKey?
- A)
messageType(e.g.: “text”, “image”, “video”) — 3 distinct values - B)
timestampof the message send time - C)
conversationId(unique identifier of each conversation) ✅ - D)
"messages"(constant value to group all messages)
Answer: C —
conversationIdoffers high cardinality and distributes data evenly. Queries to “retrieve all messages from a conversation” remain within a single partition (single-partition query). Options A and D create hot partitions. Option B creates very high cardinality but queries by conversation become cross-partition.
Visual summary — Azure storage architecture
graph TD
subgraph GPv2 Storage Account
BS[Blob Storage\nBlock / Append / Page Blobs]
TS[Table Storage\nPartitionKey + RowKey]
QS[Queue Storage]
FS[File Storage]
end
subgraph Cosmos DB
NoSQL[NoSQL API\nJSON Documents]
Mongo[MongoDB API]
Graph[Gremlin API]
end
subgraph Cache
Redis[Azure Cache\nfor Redis]
end
subgraph Relational database
SQL[Azure SQL Database\nADO.NET / EF Core]
end
subgraph Distribution
CDN[Azure CDN\nDistributed POPs]
SW[Static Website\n$web container]
end
App[Application\nASP.NET Core] --> BS
App --> TS
App --> NoSQL
App --> Redis
App --> SQL
CDN --> BS
CDN --> SW
SW --> BS
| Service | Main SDK | Recommended singleton class |
|---|---|---|
| Azure Blob Storage | Azure.Storage.Blobs | BlobServiceClient |
| Azure Table Storage | Azure.Data.Tables | TableServiceClient |
| Azure Cosmos DB | Microsoft.Azure.Cosmos | CosmosClient |
| Azure Cache for Redis | StackExchange.Redis | IConnectionMultiplexer |
| Azure SQL Database | Microsoft.Data.SqlClient + EF Core | DbContext via DI |
Search Terms
az-204 · cloud · storage · databases · azure · developer · microsoft · blob · cosmos · sdk · change · feed · cdn · lifecycle · static · website · partition · policy · architecture · available · connection · container · management · operations