Section 06

Databases, data movement & protection

Choose the right store, scale it, back it up, migrate it safely, and move data for analytics (ETL/ELT/CDC).

6.1 SQL vs NoSQL Added

SQL: tables, fixed schema, joins, strong transactions. NoSQL: flexible models built for scale and specific access patterns.

Relational (SQL)

SQL Server, PostgreSQL, Oracle, Azure SQL

Transactions, ledgers, complex queries

Key-value

Redis, DynamoDB

Sessions, cache, lookups by key

Document

MongoDB, Cosmos DB

JSON aggregates, catalogues, profiles

Wide-column

Cassandra, HBase

Massive writes, time-ordered data

Graph

Neo4j, Cosmos Gremlin

Relationships, fraud rings, recommendations

Time-series / Vector / Search

InfluxDB · pgvector, Pinecone · Elasticsearch

Metrics · AI similarity · full-text

  • Model NoSQL around access patterns (queries first). Model SQL around entities (normalise first).
  • "Polyglot persistence": use the right store per service, but every new store adds operational cost.
  • Default to relational unless scale, schema flexibility or the access pattern clearly says otherwise.
How to implement it
Data modelPackageNotes
RelationalMicrosoft.EntityFrameworkCore.SqlServer, Npgsql.EntityFrameworkCore.PostgreSQL; Dapper on Microsoft.Data.SqlClient / NpgsqlEF Core 10 for most work. Dapper for hand-tuned SQL on hot paths. EF Core 10 maps complex types to the new SQL Server 2025 / Azure SQL json column type
DocumentMicrosoft.Azure.Cosmos, Microsoft.EntityFrameworkCore.Cosmos, MongoDB.Driver, MongoDB.EntityFrameworkCoreThe native SDKs give full control (partition keys, RU costs, change feed). The EF providers are handy for simple aggregates
Key-valueStackExchange.RedisSessions, counters, lookups by key
Wide-column / GraphCassandraCSharpDriver; Neo4j.DriverCassandra (or Cosmos DB for Apache Cassandra); Cypher queries for graphs
Vector / SearchEF Core 10 SqlVector<float> + EF.Functions.VectorDistance; Pgvector.EntityFrameworkCore; Elastic.Clients.ElasticsearchSimilarity search in SQL Server 2025 / Azure SQL or PostgreSQL, without a separate vector DB
// EF Core 10: vector column + similarity search on Azure SQL / SQL Server 2025
public class Doc
{
    public int Id { get; set; }
    public string Text { get; set; } = "";
    [Column(TypeName = "vector(1536)")] public SqlVector<float> Embedding { get; set; }
}

var top3 = await db.Docs
    .OrderBy(d => EF.Functions.VectorDistance("cosine", d.Embedding, queryVector))
    .Take(3)
    .ToListAsync();

6.2 ACID vs BASE & isolation levels Added

ACID (relational)

  • Atomicity: all or nothing
  • Consistency: constraints always hold
  • Isolation: concurrent transactions don't interfere
  • Durability: committed = survives a crash

BASE (many NoSQL)

  • Basically Available
  • Soft state: may change without input as copies sync
  • Eventually consistent
Isolation levelDirty readNon-repeatable readPhantom read
Read uncommitted❌ possible❌❌
Read committed (common default)✅ prevented❌❌
Repeatable read✅✅❌
Serializable✅✅✅
  • Higher isolation = more correct, but more locking and lower throughput. Snapshot isolation (MVCC) is a popular middle ground.
How to implement it
NeedLibrary / APINotes
Atomic unit of workSaveChangesAsync (EF Core)One SaveChanges call already runs in a single transaction
Explicit transaction + isolationdb.Database.BeginTransactionAsync(IsolationLevel.…); SqlConnection.BeginTransactionSystem.Data.IsolationLevel: ReadCommitted, RepeatableRead, Serializable, Snapshot
Ambient transactionbuilt-in System.Transactions.TransactionScopeAlways pass TransactionScopeAsyncFlowOption.Enabled with async code. Avoid escalating to distributed transactions; use the outbox instead
Optimistic concurrency[Timestamp] / IsRowVersion(), IsConcurrencyToken()EF adds the version to the WHERE clause and throws DbUpdateConcurrencyException on a lost update
BASE / tunable consistencyMicrosoft.Azure.Cosmos ConsistencyLevelStrong, Bounded Staleness, Session (default), Consistent Prefix, Eventual
public class Account
{
    public int Id { get; set; }
    public decimal Balance { get; set; }
    [Timestamp] public byte[] RowVersion { get; set; } = [];   // optimistic concurrency
}

// Transfer: both updates commit together, or neither does
await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Snapshot, ct);
var from = await db.Accounts.SingleAsync(a => a.Id == fromId, ct);
var to   = await db.Accounts.SingleAsync(a => a.Id == toId, ct);
from.Balance -= amount;
to.Balance   += amount;
await db.SaveChangesAsync(ct);   // throws DbUpdateConcurrencyException if someone else changed a row
await tx.CommitAsync(ct);

Snapshot needs ALLOW_SNAPSHOT_ISOLATION ON in SQL Server (Azure SQL has it on). With EnableRetryOnFailure, wrap manual transactions in db.Database.CreateExecutionStrategy().ExecuteAsync(…).

6.3 Indexing Added

An index is like a book's index: it finds rows without scanning the whole table. Reads get faster, writes get slower.

  • Index columns used in WHERE, JOIN and ORDER BY. Check the execution plan. Too many indexes slow down inserts and updates.
How to implement it
Index typeEF Core API
Single / compositeHasIndex(p => new { p.CustomerId, p.CreatedAt }), with IsDescending() per column if needed
Unique.IsUnique(), also your last line of defence for idempotency keys and business keys
Covering.IncludeProperties(…) (SQL Server and Npgsql providers)
Filtered / partial.HasFilter("[Status] = 'Pending'")
Clustered / methodSQL Server: .IsClustered(). PostgreSQL (Npgsql): .HasMethod("hash") / "gin"
Check the planquery.ToQueryString() to get the SQL, then look at the execution plan; LogTo / OpenTelemetry for slow queries
modelBuilder.Entity<Payment>(b =>
{
    // Composite: helps WHERE CustomerId = @c ORDER BY CreatedAt (leftmost-prefix rule)
    b.HasIndex(p => new { p.CustomerId, p.CreatedAt })
     .IncludeProperties(p => new { p.Amount, p.Status });       // covering: no key lookups

    b.HasIndex(p => p.Reference).IsUnique();
    b.HasIndex(p => p.Status).HasFilter("[Status] = 'Pending'"); // small, targeted index
});
// then: dotnet ef migrations add AddPaymentIndexes

Cosmos DB indexes every property by default. Tune it with IndexingPolicy on the container (Microsoft.Azure.Cosmos) to exclude paths you never query and cut write RU cost.

6.4 Unique ID generation Added

Every record needs an ID that is unique everywhere, ideally sortable by time and friendly to indexes, and created without a central bottleneck.

🔢 Auto-increment

IDENTITY / SERIAL: 1, 2, 3…

  • Simple, small, perfectly ordered
  • ✗ One database hands them out, so it doesn't work across shards or regions without coordination
  • ✗ Leaks business data: order 10,500 today and 10,900 tomorrow = 400 orders a day. IDs are guessable

🎲 UUID v4

128 bits, random.

  • Create anywhere, no coordination, unguessable
  • ✗ Random order: each insert lands on a random index page, so page splits and fragmentation

⏱ UUID v7

128 bits: 48-bit Unix millisecond timestamp first, then random bits (RFC 9562, 2024).

  • Create anywhere, and new IDs sort after old ones
  • Inserts go to the end of the index, like auto-increment

🔤 ULID

128 bits: 48-bit timestamp + 80 random bits, written as 26 Base32 characters (01JA2X…).

  • Sortable as text, URL-friendly
  • A community spec, not an RFC. UUID v7 is now the standard choice

❄ Snowflake ID

64 bits: timestamp + machine ID + sequence (from Twitter, 2010).

  • Fits a BIGINT: half the size of a UUID
  • Each node generates its own, roughly time-ordered
  • ✗ Needs unique machine IDs and sane clocks

🗂 Sequences & Hi/Lo

A DB sequence hands out numbers independent of any table. Hi/Lo: the app reserves a block (e.g. 10 IDs) per round trip and assigns them in memory.

  • Fewer round trips; IDs known before insert
  • Gaps when a block isn't used up (that's fine)

Snowflake ID: 64 bits

One ID = 64 bits = fits in a BIGINT timestamp: 41 bits machine: 10 bits sequence: 12 bits milliseconds since a custom epoch worker 0–1,023 seq = 0 seq = 1 seq = 2 seq = 3 sign bit (always 0) about 69 years of IDs 1,024 nodes 4,096 IDs per ms per node Timestamp first → IDs sort by creation time, and new rows go to the end of the index Machine ID → every node creates IDs on its own, with no central counter Sequence → counts up within the same millisecond, then resets to 0 ✓ sortable by time ✓ 8 bytes, not 16 ✓ no coordination per ID ✗ clock going backwards = risk

Compare

ID typeSizeTime-ordered?Created byIndex-friendly?Watch out
Auto-increment4–8 bytesYesThe databaseYesSingle writer; leaks counts; guessable
Sequence / Hi/Lo8 bytesMostlyThe database, in blocksYesStill one database owns the sequence
UUID v416 bytesNoAnyoneNo: random insertsFragmentation, bigger indexes
UUID v716 bytesYes (ms)AnyoneYes (see the SQL Server note below)Reveals creation time
ULID16 bytes / 26 charsYes (ms)AnyoneYesNot an RFC; store as binary, not text
Snowflake8 bytesRoughly (ms)Each nodeYesAssign worker IDs; handle clock skew

Choosing a clustered index key

  • Random UUID v4 as a clustered key = page splits and fragmentation. Time-ordered IDs append to the end of the index.
  • Distributed systems and sharding (see 6.7) need IDs that don't depend on one database: UUID v7 or Snowflake.
  • Sequential IDs in public URLs let anyone count your customers or guess other records. Always check authorisation anyway.
How to implement it
Library / APIPackageUse it for
UUID v7built-in Guid.CreateVersion7() (since .NET 9)Time-ordered GUIDs created in the app; Guid.Version tells you the version
Hi/Lo and sequencesMicrosoft.EntityFrameworkCore.SqlServer (UseHiLo, HasSequence); Npgsql has the sameEF reserves blocks of IDs from a DB sequence, so it knows keys before SaveChanges
Snowflake-style IDsIdGen64-bit IDs per worker; configure the epoch and bit split
ULIDUlid (Cysharp)Fast ULID struct with Ulid.NewUlid(); converts to and from Guid
Guid paymentId = Guid.CreateVersion7();          // .NET 9+: UUID v7, time-ordered

var generator = new IdGenerator(7);              // IdGen: worker ID 7 (unique per node)
long orderId = generator.CreateId();             // 64-bit Snowflake-style

Ulid traceId = Ulid.NewUlid();                   // Cysharp Ulid: 26-char sortable text

// EF Core: Hi/Lo on SQL Server - one round trip reserves a block of IDs
modelBuilder.HasSequence<long>("OrderIds").IncrementsBy(10);
modelBuilder.Entity<Order>().Property(o => o.Id).UseHiLo("OrderIds");

SQL Server sorts uniqueidentifier by its last bytes first, so Guid.CreateVersion7() values are not in index order there. On SQL Server, let EF Core generate GUID keys (its default generator is SQL Server-friendly) or use NEWSEQUENTIALID(), or use BIGINT. On PostgreSQL, UUID v7 works well, and PostgreSQL 18 has a built-in uuidv7().

Your primary key choice can quietly slow down every insert 🐢

🔢 Auto-increment: fast and ordered, but one DB owns it, and it tells competitors how many orders you take

🎲 UUID v4: unique anywhere, but random, so inserts land all over the index

⏱ UUID v7: unique anywhere AND time-ordered. Inserts append to the end

❄ Snowflake: 64 bits = timestamp + machine + sequence. Fits a BIGINT

Rule of thumb: time-ordered IDs for keys, opaque IDs in public URLs. What does your team use?

#Databases #SystemDesign #DotNet #SoftwareArchitecture

6.6 Geospatial indexing Added

A spatial index turns 2D locations into something a database can search fast, so "find the nearest ATMs within 1 km" doesn't scan every row.

Why it's needed: a normal index on latitude helps one range, but "near me" is a 2D question. Spatial indexes split the map into cells or boxes so only nearby candidates are checked.

🔠 Geohash

Interleaves latitude and longitude bits into a short string. Shared prefix = same area: r3gx2 is a cell in Sydney's CBD. Longer = smaller cell. Works with a normal B-tree (LIKE 'r3gx2%').

🌳 Quadtree

Split the map into 4 squares; keep splitting any square with too many points. Dense cities get small cells, the outback gets big ones. Popular for in-memory indexes.

📦 R-tree

Groups nearby shapes into bounding rectangles inside a balanced tree. Handles points, lines and polygons. Used by PostGIS (GiST indexes), SQLite and Oracle.

⬡ S2 and H3

S2 (Google) maps the sphere onto cube faces with 64-bit cell IDs. H3 (Uber) uses hexagons, so all neighbours are about the same distance away. Good for aggregating ("rides per hexagon").

StoreSpatial support"Within 1 km" query
PostgreSQL + PostGISgeography / geometry types, GiST (R-tree style) indexST_DWithin(location, @here, 1000)
SQL Server / Azure SQLgeography / geometry, grid-based spatial indexlocation.STDistance(@here) <= 1000
Redis GEOGeohash stored in a sorted setGEOSEARCH atms FROMLONLAT 151.2 -33.87 BYRADIUS 1 km ASC
Cosmos DBGeoJSON points and polygons, spatial indexST_DISTANCE(c.location, @here) < 1000
Elasticsearch / OpenSearchgeo_point, geo_shapegeo_distance query
  • Pattern: coarse filter with the index (cells or boxes), then an exact distance check on the few candidates.
  • Geohash edge problem: nearby points can have different prefixes, so always include the neighbouring cells.
  • Use geography (round earth, metres) for real-world distances; SRID 4326 = WGS 84, the GPS coordinate system.
  • GeoJSON, NetTopologySuite and Redis take longitude first, then latitude. Mixing them up puts Sydney in Antarctica.
How to implement it
Library / APIPackageUse it for
Spatial typesNetTopologySuitePoint, Polygon and spatial operations in .NET
EF Core spatialMicrosoft.EntityFrameworkCore.SqlServer.NetTopologySuite, Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuiteMap Point to geography; LINQ Distance and IsWithinDistance run in the database
Redis GEOStackExchange.Redis (GeoAddAsync, GeoSearchAsync)Fast "nearby" lookups on a cached set of locations
Cosmos DB spatialMicrosoft.Azure.Cosmos (Microsoft.Azure.Cosmos.Spatial.Point)GeoJSON properties queried with ST_DISTANCE / ST_WITHIN
// EF Core + SQL Server geography. Remember: X = longitude, Y = latitude
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseSqlServer(cs, sql => sql.UseNetTopologySuite()));

public class Atm { public int Id { get; set; } public Point Location { get; set; } = null!; }

var here = new Point(151.2061, -33.8732) { SRID = 4326 };
var nearest = await db.Atms
    .Where(a => a.Location.IsWithinDistance(here, 1000))       // metres on geography
    .OrderBy(a => a.Location.Distance(here))
    .Take(5)
    .ToListAsync(ct);

// Redis: the same idea, in memory
await redis.GeoAddAsync("atms", 151.2061, -33.8732, "atm:42");
var near = await redis.GeoSearchAsync("atms", 151.2093, -33.8688,
    new GeoSearchCircle(1, GeoUnit.Kilometers), count: 5);

"Find the nearest ATM" looks simple. It's a great system design question 📍

🔠 Geohash: turn lat/long into a string. Shared prefix = same area

⚠ Edge problem: an ATM 300 m away can have a different prefix, so search the 8 neighbouring cells too

🌳 Quadtrees and R-trees adapt to dense cities; S2 and H3 cover the globe with cells

✅ Then do an exact distance check on the few candidates

Coarse filter with an index, exact check on the shortlist. The same trick works in many designs.

#SystemDesign #Geospatial #Databases #InterviewPrep

6.7 Database scaling

Climb the ladder: try the cheap, simple steps first, and only move to sharding when you must.

The scaling ladder (bottom = do first)
8. Polyglot / CQRS / NewSQL (Spanner, CockroachDB, Cosmos DB)
7. Sharding: split rows across many databases
6. Partitioning: split a big table inside one database
5. Federation: split by function (users DB, orders DB)
4. Read replicas: send reads to copies
3. Caching: Redis in front of the DB
2. Vertical scale + connection pooling
1. Optimise queries, indexes, schema

Replication

App writes
→
Primary (leader)
⇒replicate (sync / async)
Replica 1 (reads)
Replica 2 (reads)
←
App reads

Partitioning vs sharding

Sharding strategyHow➕➖
RangeBy value range (date, A–H)Range queries are easyHotspots (all new data on the last shard)
Hashhash(key) % N or consistent hashingEven spreadRange queries hit all shards. Resharding is hard
Directory / lookupA lookup table maps key → shardFlexible movesThe lookup is a SPOF and adds latency
Geo / tenantBy region or customerData residency, isolationUneven tenant sizes
  • A good shard key has high cardinality, spreads load evenly and matches your main query.
  • Sharding pain: cross-shard joins and transactions, rebalancing, global unique IDs (use Snowflake-style IDs or UUIDv7, see 6.4), more operations work.
  • Connection pooling (PgBouncer, built-in pools) is often the quickest win when many app instances overwhelm the DB.
  • Denormalisation trades storage and write complexity for faster reads.
How to implement it
Ladder stepLibrary / API
1. Optimise queriesAsNoTracking(), Select projections, AsSplitQuery() when you Include several collections, EF.CompileAsyncQuery for hot queries, ExecuteUpdateAsync / ExecuteDeleteAsync for bulk changes
2. Connection poolingAddDbContextPool (reuses DbContext objects). Microsoft.Data.SqlClient and Npgsql pool connections by default (Max Pool Size in the connection string)
3. CachingHybridCache (see section 05)
4. Read replicasA second read-only DbContext with ApplicationIntent=ReadOnly (Azure SQL / Always On readable secondaries)
6–7. Partitioning / shardingCosmos DB: choose the PartitionKey (hierarchical keys for big tenants). PostgreSQL: Citus, used through plain Npgsql. Global IDs: Guid.CreateVersion7() (time-ordered UUIDv7)
var cs = builder.Configuration.GetConnectionString("sql");

// Writes go to the primary
builder.Services.AddDbContextPool<AppDbContext>(o => o.UseSqlServer(cs));

// Reads go to a readable secondary, with no change tracking
builder.Services.AddDbContextPool<ReadDbContext>(o => o
    .UseSqlServer(cs + ";ApplicationIntent=ReadOnly")
    .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

Replicas lag. After a write, read your own data from the primary (or cache it) to avoid "my update disappeared" bugs.

Don't shard your database on day one 🙅

The scaling ladder: 1) fix queries and indexes → 2) bigger box + connection pooling → 3) cache → 4) read replicas → 5) split by function → 6) partition → 7) shard.

Every rung adds complexity. Climb only when the metrics tell you to.

#Databases #SystemDesign #Scalability

6.8 Storage types: block vs file vs object Added

Block storage is a raw disk for one machine, file storage is a shared folder, and object storage is a huge, cheap bucket of files you reach over HTTP.

💽 Block

A virtual hard disk split into fixed-size blocks. Attached to one VM, which formats it with a file system.

  • Lowest latency, high IOPS, in-place updates
  • Databases, VM OS disks, anything needing a "real" disk
  • Azure Managed Disks, AWS EBS

📁 File

Folders and files shared over the network (SMB or NFS) by many machines at once.

  • Familiar paths, file locking, shared access
  • Lift-and-shift apps, shared config, team drives, content for several servers
  • Azure Files, Azure NetApp Files, AWS EFS, FSx

🪣 Object

A flat namespace of objects (data + metadata + key), read and written through an HTTP API.

  • Practically unlimited scale, cheapest per GB, very high durability
  • Documents, images, backups, logs, data lakes, static websites
  • Azure Blob Storage, Amazon S3
BlockFileObject
AccessMounted as a disk by one VM (some disks can be shared in clusters)Mounted share: SMB / NFS pathsHTTP(S) REST API or SDK, by key or URL
UpdatesChange any block in placeChange part of a fileUsually replace the whole object (versioning keeps old copies)
LatencySub-millisecond to a few msMillisecondsTens of ms; built for throughput
ScaleSize of the diskSize of the sharePetabytes, billions of objects
Cost per GBHighestMediumLowest, with cheaper tiers

Object storage tiers and lifecycle

🔒 Immutability (WORM)

Write once, read many. Nobody, not even an admin, can change or delete the object until the retention period ends.

  • Azure: immutable blob storage with time-based retention or legal hold
  • AWS: S3 Object Lock (governance or compliance mode)
  • Used for records retention and ransomware-proof backups (see 6.9)

🔗 SAS and presigned URLs

A short-lived URL that lets a client read or upload one object directly, without passing the file through your API.

  • Azure: SAS; prefer a user delegation SAS (signed with Entra ID, not the account key)
  • AWS: S3 presigned URL
  • Keep expiry short (minutes), scope to one object and the least permission
NeedUse
A database or VM diskBlock (Premium SSD / Ultra Disk, EBS gp3 / io2)
Several servers sharing one folder; an old app that expects file pathsFile (Azure Files, EFS)
Documents, images, statements, backups, logs, a data lakeObject (Blob Storage, S3)
Users upload or download large filesObject + SAS / presigned URLs straight from the browser
  • Block = one machine's disk. File = shared folder. Object = HTTP bucket at massive scale.
  • Use lifecycle rules to move data to cheaper tiers automatically, and mind the minimum storage periods and rehydration time of Archive.
  • Don't stream big files through your API: hand out a short-lived SAS or presigned URL.
How to implement it
Library / APIPackageUse it for
Blob StorageAzure.Storage.BlobsUpload and download, AccessTier (Hot / Cool / Cold / Archive), SetAccessTierAsync, SAS with BlobSasBuilder or GenerateSasUri, immutability policies
Azure FilesAzure.Storage.Files.SharesManage SMB/NFS shares and files from code (apps usually just mount the share)
Amazon S3AWSSDK.S3PutObjectAsync, storage classes, presigned URLs (GetPreSignedURL)
Lifecycle rulesStorage account policy (Bicep / Terraform) or Azure.ResourceManager.StorageTiering and deletion are platform rules, not app code
var service = new BlobServiceClient(new Uri(accountUrl), new DefaultAzureCredential());
var blob = service.GetBlobContainerClient("statements").GetBlobClient($"{accountId}/2026-10.pdf");

await blob.UploadAsync(pdfStream, new BlobUploadOptions { AccessTier = AccessTier.Hot });

// Short-lived, read-only link signed with Entra ID (user delegation SAS)
var expires = DateTimeOffset.UtcNow.AddMinutes(15);
UserDelegationKey key = await service.GetUserDelegationKeyAsync(DateTimeOffset.UtcNow, expires);
var sas = new BlobSasBuilder(BlobSasPermissions.Read, expires)
{
    BlobContainerName = blob.BlobContainerName,
    BlobName = blob.Name,
    Resource = "b"                                   // a single blob
};
Uri link = new BlobUriBuilder(blob.Uri) { Sas = sas.ToSasQueryParameters(key, service.AccountName) }.ToUri();

The identity requesting the user delegation key needs the "Storage Blob Delegator" role (or a data role that includes it), and the SAS can never grant more than that identity's own access.

Block vs file vs object storage, in one minute 🗄

💽 Block: a raw disk for one machine. Fast. Databases live here

📁 File: a shared folder over SMB/NFS. Many servers, one path

🪣 Object: a bucket over HTTP. Massive scale, cheapest per GB. Documents, backups, data lakes

💸 Then let lifecycle rules move old objects Hot → Cool → Cold → Archive, and lock records with WORM

Bonus: never stream big files through your API. Give the client a 15-minute SAS or presigned URL.

#CloudStorage #Azure #AWS #SystemDesign

6.9 Database backup strategies

Backups are insurance against deletion, corruption, ransomware and disasters. A backup only counts if you've tested the restore.

Full: every backup = everything. Incremental: Δ since the LAST backup of any type. Differential: Δ since the LAST FULL, so it grows every day.

TypeBackup timeStorageRestore speed
FullSlowestMostFastest (one set)
IncrementalFastestLeastSlowest (whole chain)
DifferentialMedium (grows)MediumMedium (full + latest diff)

📜 Transaction log backups → PITR

Log backups every few minutes let you restore to any point in time, like "just before the bad DELETE at 10:42".

📸 Snapshots

Storage- or volume-level copies, near-instant. Make sure they're application-consistent.

🔤 Logical vs physical

Logical = SQL dump (portable, slow). Physical = data files (fast, same engine).

🔥 Hot vs cold

Hot = DB online during backup. Cold = DB offline (consistent, but downtime).

🌏 Geo-redundant

Copy to another region (GRS) for disaster recovery.

🔒 Immutable / air-gapped

WORM storage that can't be deleted or encrypted by ransomware.

  • 3-2-1 rule: 3 copies of data, on 2 different media, 1 offsite. Modern version: 3-2-1-1-0 = plus 1 immutable or air-gapped copy, and 0 errors after restore verification.
  • Retention: GFS (Grandfather-Father-Son), e.g. daily for 30 days, monthly for 12 months, yearly for 7 years (compliance).
  • Encrypt backups. Protect and back up the keys separately.
  • Backup frequency sets your RPO. Restore speed sets your RTO.
How to implement it
Library / APIPackageUse it for
Azure SQL PITR + LTRAzure.ResourceManager.SqlBackups are automatic. Use the SDK to set short-term retention and long-term (GFS) retention policies, and to script point-in-time restores for drills
SQL Server backup / restoreMicrosoft.SqlServer.SqlManagementObjects (SMO Backup / Restore)Full, differential and log backups from code. Or run BACKUP DATABASE … TO URL through Microsoft.Data.SqlClient
Immutable copyAzure.Storage.Blobs (SetImmutabilityPolicyAsync, SetLegalHoldAsync)The "1 immutable" in 3-2-1-1-0: WORM blobs that even an admin can't delete before expiry
Encryption keysAzure.Security.KeyVault.KeysCustomer-managed keys for TDE and backups, kept and backed up separately
Cosmos DBPlatform setting (continuous backup)Point-in-time restore is configured on the account, not in app code

The "0 errors" part needs a scheduled restore test: restore to a scratch database in a pipeline, then run row counts and checksums against it.

Your backup strategy is only as good as your last successful restore 🔁

✅ 3 copies ✅ 2 media ✅ 1 offsite ✅ 1 immutable ✅ 0 errors on restore test

That's the 3-2-1-1-0 rule. Ransomware goes after your backups first. Make one copy it can't touch.

#DataProtection #CyberSecurity #Databases

6.10 Data migration strategies

Moving data from a source to a target system without losing it, corrupting it, or stopping the business.

Lifecycle

1. Assess & profilevolume, quality
→
2. Map & designsource → target rules
→
3. Extract & transformcleanse
→
4. Loadtrial runs / mocks
→
5. Validate & reconcilecounts, checksums
→
6. Cutovergo / no-go
→
7. Decommission

💥 Big bang

Move everything in one cutover window (e.g. a weekend).

  • ➕ Simple. No dual running.
  • ➖ High risk, downtime, hard to roll back.

💧 Trickle / phased

Migrate in waves (by product, region or customer segment) while old and new run side by side.

  • ➕ Low risk, learn as you go.
  • ➖ Longer, needs sync between systems.

🔄 CDC replication

Bulk-load history, then stream ongoing changes (CDC) until cutover. Near-zero downtime.

🪞 Parallel run

Run old and new together and compare outputs before switching. Common in banking.

✍️ Dual write

The app writes to both systems. Risky (partial failures). Prefer CDC or outbox.

🧩 Expand → migrate → contract

For schema changes: add the new column, backfill, switch reads, then drop the old one. Zero downtime.

Cloud migration: the 7 Rs

Rehost (lift & shift) · Relocate · Replatform (lift & reshape) · Refactor (re-architect) · Repurchase (move to SaaS) · Retire · Retain

  • Data quality issues show up during migration. Profile early and agree on cleansing rules with the business.
  • Reconcile: row counts, sums of amounts, hash totals, sample record checks.
  • Always have a rollback plan and a rehearsed cutover runbook.
How to implement it
Library / APIPackageUse it for
EF Core migrationsMicrosoft.EntityFrameworkCore.Design + dotnet-ef toolVersioned schema changes. dotnet ef migrations bundle builds one executable that a pipeline runs before the deployment
SQL-script migrationsdbup-sqlserver / FluentMigrator.RunnerPlain, reviewable SQL scripts (DbUp) or C# migrations across many databases (FluentMigrator)
Bulk copyMicrosoft.Data.SqlClient SqlBulkCopy; Npgsql BeginBinaryImportAsync (COPY)Fast history loads, streamed straight from a source reader
CDC syncSee 6.12 (SQL CDC, Cosmos change feed, Debezium + Confluent.Kafka)Keep the new system in sync until cutover
Expand / contractSeparate EF migrations per stepMigration 1 adds the column, a job backfills it, the app switches, migration 2 drops the old column
// Bulk-load history from the legacy DB into the new one, streaming row by row
await using var cmd = new SqlCommand("SELECT Id, Name, Balance FROM dbo.Accounts", legacyConn);
await using var source = await cmd.ExecuteReaderAsync(ct);

using var bulk = new SqlBulkCopy(targetConn)
{
    DestinationTableName = "dbo.Accounts",
    BatchSize = 10_000,
    EnableStreaming = true
};
await bulk.WriteToServerAsync(source, ct);
// then reconcile: compare COUNT(*) and SUM(Balance) on both sides

Avoid Database.Migrate() at app start-up in production. Run the bundle once from the pipeline, so many instances don't race and the app doesn't need DDL rights.

6.11 ETL vs ELT

Same three steps, different order: transform before loading (ETL) or inside the target warehouse (ELT).

ETL
Extractsources
→
TransformETL server
→
Loadwarehouse
ELT
Extract
→
Loadraw into lake / warehouse
→
Transformin-warehouse SQL
ETLELT
Where it transformsSeparate engine, before loadInside the target (cloud compute)
Raw data kept?Usually notYes, so you can re-transform later
Best forOn-prem warehouses, strict cleansing, PII masking before landingCloud warehouses and lakehouses, big data, agile analytics
ToolsInformatica, SSIS, Talend, ADF data flowsdbt + Snowflake / Databricks / Synapse / BigQuery, Fivetran
Trade-offSlower to change, but clean data landsFlexible and fast, but raw sensitive data lands (govern it)
  • Reverse ETL: push warehouse insights back into operational tools (CRM).
  • Zero-ETL: the platform replicates operational data into analytics automatically.
  • For PII, ETL lets you mask or tokenise before data lands. In ELT, do it at landing with strict access controls.
How to implement it
Library / APIPackageUse it for
In-process pipelinesbuilt-in System.Threading.Channels; System.Threading.Tasks.DataflowExtract → transform → load stages with back-pressure and parallelism
File formatsCsvHelper, Parquet.NetRead CSV feeds; write columnar Parquet for the lake
LoadSqlBulkCopy (Microsoft.Data.SqlClient), Npgsql binary COPYFast batch inserts into the warehouse or staging tables
Run itMicrosoft.Azure.Functions.Worker (timer / blob triggers); BackgroundServiceServerless or hosted ETL jobs
Orchestrate ELTAzure.ResourceManager.DataFactory; Microsoft.Fabric.ApiStart and monitor Data Factory or Fabric pipelines from code. Heavy transforms stay in the platform (Spark, SQL, dbt)
// ETL in one process: extract CSV, mask PII before it lands, load in batches
var channel = Channel.CreateBounded<Txn>(10_000);          // back-pressure

var extract = Task.Run(async () =>
{
    using var csv = new CsvReader(File.OpenText(path), CultureInfo.InvariantCulture);
    await foreach (var row in csv.GetRecordsAsync<Txn>(ct))
        await channel.Writer.WriteAsync(row with { CardNumber = Mask(row.CardNumber) }, ct);
    channel.Writer.Complete();
});

var batch = new List<Txn>(5_000);
await foreach (var txn in channel.Reader.ReadAllAsync(ct))
{
    batch.Add(txn);
    if (batch.Count == 5_000) { await LoadAsync(batch, ct); batch.Clear(); }  // SqlBulkCopy
}
if (batch.Count > 0) await LoadAsync(batch, ct);
await extract;

Don't start new work on .NET for Apache Spark; it is no longer maintained. For Spark, use the platform (Fabric, Databricks, Synapse) and trigger it from .NET.

ETL vs ELT: the only difference is where the "T" lives 🔤

ETL: clean the vegetables before putting them in the fridge.

ELT: put everything in a huge fridge (cloud warehouse) and prep when you cook.

Cheap cloud compute made ELT popular. Data governance keeps ETL alive.

#DataEngineering #ETL #Analytics

6.12 Change Data Capture (CDC) Added

Capture every insert, update and delete from a database and stream it to other systems in near real time.

Source DBtransaction log
→
CDC connectorDebezium / GoldenGate / SQL CDC
→
Kafka / Event Hubs
→
Consumerscache, search, lake, microservices
  • CDC is a key enabler for near-real-time integration, strangler migrations and cache invalidation, without touching legacy app code.
How to implement it
SourceLibrary / APINotes
SQL Server CDC / Change TrackingMicrosoft.Data.SqlClient polling cdc.fn_cdc_get_all_changes_… or CHANGETABLE(CHANGES …)Log-based CDC built into SQL Server. Store the last LSN / version you processed
SQL Server 2025 change event streaming previewAzure.Messaging.EventHubs.ProcessorThe engine pushes row changes as CloudEvents to Azure Event Hubs. The feature itself is still in preview
Cosmos DB change feedMicrosoft.Azure.Cosmos change feed processorScales across instances using a lease container
Debezium (Postgres, MySQL, Oracle…)Confluent.Kafka consumerDebezium writes changes to Kafka (or Event Hubs' Kafka endpoint); .NET consumes them
Outbox (app-level)EF Core: save the event row in the same SaveChanges; a BackgroundService publishes itWhen you own the app code; see section 10
// Cosmos DB change feed: react to every insert/update, e.g. invalidate a cache
var processor = ordersContainer
    .GetChangeFeedProcessorBuilder<Order>("order-projector", HandleChangesAsync)
    .WithInstanceName(Environment.MachineName)
    .WithLeaseContainer(leasesContainer)
    .Build();
await processor.StartAsync();

async Task HandleChangesAsync(ChangeFeedProcessorContext context,
    IReadOnlyCollection<Order> changes, CancellationToken ct)
{
    foreach (var order in changes)
        await cache.RemoveAsync($"order:{order.Id}", ct);
}

The default change feed mode doesn't show deletes. Use a soft-delete flag plus TTL, or the newer "all versions and deletes" mode where your account supports it.

6.13 Data warehouse vs Data lake vs Lakehouse Added

🏛 Warehouse

Structured, schema-on-write, SQL, BI. Clean and fast, less flexible.

🌊 Lake

Any data (raw files) on cheap object storage, schema-on-read. Flexible, but can become a "data swamp".

🏠 Lakehouse

Lake storage + warehouse features (ACID tables via Delta / Iceberg / Hudi). Databricks, Fabric.

Medallion architecture
🥉 Bronzeraw, as ingested
→
🥈 Silvercleaned, conformed
→
🥇 Goldbusiness-ready aggregates
  • Batch vs streaming processing: Lambda architecture = a batch layer plus a speed layer. Kappa architecture = streaming only, replay for reprocessing.
How to implement it
Library / APIPackageUse it for
Lake storage (ADLS Gen2)Azure.Storage.Files.DataLakeWrite files into bronze / silver / gold folders with a real directory structure and ACLs
Columnar filesParquet.NetWrite and read Parquet, the file format under Delta and Iceberg tables
Local analyticsDuckDB.NET.Data.FullRun SQL over Parquet files in-process (tests, small jobs, quick checks)
Warehouse / lakehouse SQLMicrosoft.Data.SqlClientFabric Warehouse, Fabric SQL analytics endpoints and Synapse SQL all speak TDS; connect with Entra ID
Fabric automationMicrosoft.Fabric.ApiCreate workspaces, lakehouses and items, and run jobs through the Fabric REST API
// Quick check over the silver layer without a cluster
using var conn = new DuckDBConnection("DataSource=:memory:");
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
    SELECT merchant, SUM(amount) AS total
    FROM read_parquet('silver/transactions/*.parquet')
    GROUP BY merchant ORDER BY total DESC LIMIT 10
    """;
using var reader = cmd.ExecuteReader();

There is no official Microsoft Delta Lake or Iceberg library for .NET. Land Parquet from .NET, and let Fabric, Databricks or Synapse Spark manage the ACID tables.

⚡ Quick-fire Q&A

Read replicas or sharding?

Replicas scale reads, and they're simple. Sharding scales writes and storage, and it's complex. Most systems are read-heavy, so start with replicas and caching.

How do you achieve near-zero-downtime migration?

Bulk-copy history, keep it in sync with CDC, validate continuously, then do a short cutover (switch the connection or DNS) with a rollback path.

Incremental vs differential restore?

Incremental needs the full backup plus every incremental since. Differential needs the full backup plus only the latest differential.

UUID v4 or UUID v7 for a primary key?

UUID v7. Both can be created anywhere, but v7 starts with a millisecond timestamp, so new keys append to the end of the index instead of landing on random pages. On SQL Server, mind its GUID sort order (EF Core's sequential GUIDs or BIGINT may suit better).

How would you add product search to an app backed by SQL?

Keep SQL as the source of truth and push changes (CDC or outbox events) into a search engine such as Azure AI Search or Elasticsearch. It gives an inverted index, BM25 ranking, fuzzy matching and facets, about a second behind the database. LIKE '%x%' scans the table and can't rank.

Design "find the nearest 5 ATMs".

Store locations with a spatial index (PostGIS, SQL Server geography, Redis GEO or a geohash column). Coarse filter: your geohash cell plus its 8 neighbours (or ST_DWithin). Then compute exact distances on the candidates, sort and take 5. Cache results per cell because ATM locations rarely change.

When would you choose block, file or object storage?

Block for a database or VM disk (low latency, one machine). File for a folder shared by several servers or a legacy app that needs paths. Object for documents, images, backups and data lakes at scale, with lifecycle tiers and SAS / presigned URLs for direct client access.

← 05 · Caching