Chapter 19 · SQL Server and Azure SQL Provider Deep Dive
Identity, Sequences, rowversion, Computed Columns, Collations, and SQL Server Type Mapping
Move ServiceHub from the portable SQLite baseline to a deliberately isolated SQL Server provider lab and make generated values, rowversion concurrency, type facets, computed/default values, and collation semantics visible in the model, migrations, commands, and database.
Learning outcomes
Explain which generated-value behaviors come from EF Core, the SQL Server provider, and SQL Server itself.
Choose between IDENTITY, explicit sequences, and HiLo only after considering key semantics, round trips, and migration shape.
Map SQL Server rowversion as a provider-generated concurrency token without replacing the course portable Revision token silently.
Configure Unicode, length, decimal/date-time precision, computed/default columns, and collations as storage/query semantics—not C# decoration.
Inspect model metadata, migration SQL, generated DML, and failure evidence before calling a SQL Server mapping correct.
Keep the provider lab isolated so Chapter 01–18 SQLite migrations and portable domain behavior remain reproducible.
1. The problem: the same C# model does not mean the same database behavior
Through Chapter 18, ServiceHub deliberately used SQLite as the
mandatory baseline and an application-managed
Guid Revision token. Moving to SQL Server
introduces store behaviors that did not exist in that lab:
IDENTITY, sequences, HiLo allocation,
rowversion, persisted computed columns, SQL Server
collations, and richer type facets. Those are not cosmetic
provider switches. They change generated DDL, write round trips,
concurrency predicates, value precision, and sometimes query
plans.
Create a disposable
ServiceHub.SqlServerLab context/model for this
chapter. The course-wide WorkOrder and its
application-managed Revision remain valid
portable examples; the SQL Server lab adds a provider-specific
SqlServerWorkOrder with
byte[] Version so rowversion semantics are
explicit rather than hidden behind conditional mapping.
| Term | Mechanism | What to verify |
|---|---|---|
| IDENTITY | SQL Server generates a numeric value on INSERT | Migration DDL, returned key, explicit-value behavior |
| Sequence | Independent database object returns numbers | Sequence DDL, gaps, allocation policy |
| HiLo | EF obtains a high value from a sequence and allocates lows client-side | Sequence calls versus inserted rows |
| rowversion | SQL Server-generated 8-byte version changes on row update | Concurrency WHERE predicate and returned version |
| Collation | Database rules for text comparison/sort | Case/accent behavior and index compatibility |
2. Reproduce the provider boundary with a free local SQL Server path
Use SQL Server 2025 Developer edition locally (Windows installation or a supported container) for the mandatory provider lab. Developer edition is free for development/test but not a production license. Azure SQL is an optional cloud comparison, not a requirement. Keep the connection string outside source control.
dotnet add src/ServiceHub.SqlServerLab package Microsoft.EntityFrameworkCore.SqlServer --version 10.0.11dotnet add src/ServiceHub.SqlServerLab package Microsoft.EntityFrameworkCore.Design --version 10.0.11dotnet tool update --local dotnet-ef --version 10.0.11dotnet --infodotnet ef --version
var connectionString = configuration.GetConnectionString("SqlServer") ?? Environment.GetEnvironmentVariable("SERVICEHUB_SQLSERVER") ?? throw new InvalidOperationException("SQL Server connection is not configured.");services.AddDbContext<SqlServerServiceHubContext>(options => options.UseSqlServer(connectionString));
The NuGet 10.0.11 SQL Server provider depends on Microsoft.Data.SqlClient 6.1.6 or later. Record the actual server build and database compatibility level as part of every provider-specific lab because SQL Server and Azure SQL features do not move in lockstep.
3. IDENTITY is the default numeric generated-key strategy
For a numeric primary key configured as generated on add, the
SQL Server provider normally creates an
IDENTITY column. The value is generated by the
database, so EF treats a new entity key as temporary until the
INSERT returns the real value. This is different from SQLite
implementation details even though the C# property may still be
int Id.
modelBuilder.Entity<SqlServerWorkOrder>(entity =>{ entity.ToTable("work_orders"); entity.HasKey(x => x.Id); entity.Property(x => x.Id) .HasColumnName("work_order_id") .UseIdentityColumn(seed: 1, increment: 1);});
CREATE TABLE [work_orders] ( [work_order_id] int NOT NULL IDENTITY(1,1), ... CONSTRAINT [PK_work_orders] PRIMARY KEY ([work_order_id]));
Gaps in an identity are normal: rollback, caching, failover, and
deleted rows can leave holes. Do not use identity continuity as
a business guarantee. ServiceHub continues to keep
WorkOrderNumber as the externally meaningful
business key.
4. Sequences and HiLo solve different allocation problems
A SQL Server sequence is a schema object independent of any table. You can use it directly as a default or let EF use a sequence-based HiLo strategy. HiLo reduces key-generation round trips by allocating a range of IDs in memory after obtaining a high value; the tradeoff is that unused ranges and gaps become normal and the strategy is provider/model infrastructure, not business numbering.
modelBuilder.HasSequence<long>("service_ticket_seq") .StartsAt(100_000) .IncrementsBy(1);modelBuilder.Entity<SqlServerWorkOrder>() .Property(x => x.InternalTicketNumber) .HasDefaultValueSql("NEXT VALUE FOR [service_ticket_seq]");
// Alternative lab model; do not combine this with IDENTITY for the same key.modelBuilder.UseHiLo("servicehub_hilo");
Choose IDENTITY for simple store-generated numeric keys. Consider HiLo when key-generation round trips are measurable and gaps are acceptable. Use explicit sequences when multiple tables/processes intentionally share a database sequence. None of these should generate a customer-facing identifier whose continuity has legal or business meaning.
5. rowversion is a SQL Server concurrency mechanism, not a portable byte[] timestamp
SQL Server rowversion is an automatically generated
binary value that changes when the row is updated. EF maps it as
both value-generated and a concurrency token. The provider then
includes the original version in UPDATE/DELETE predicates, and a
zero-row result becomes
DbUpdateConcurrencyException. It is not a
wall-clock timestamp and has no SQLite equivalent.
public sealed class SqlServerWorkOrder{ public int Id { get; private set; } public string WorkOrderNumber { get; private set; } = null!; public string CustomerName { get; private set; } = null!; public byte[] Version { get; private set; } = [];}modelBuilder.Entity<SqlServerWorkOrder>() .Property(x => x.Version) .IsRowVersion();
UPDATE [work_orders]SET [CustomerName] = @p0OUTPUT INSERTED.[Version]WHERE [work_order_id] = @p1 AND [Version] = @p2;
The returned rowversion replaces the tracked current/original
value after a successful save. Keep the course portable
Guid Revision token for SQLite and provider-neutral
examples; use rowversion only inside the SQL Server boundary.
6. Type facets are data semantics: Unicode, precision, and time
CLR types do not specify SQL Server storage precision on their
own. Define facets that follow the domain and query workload.
For money-like values, precision/scale is a business rule; for
strings, Unicode and length affect storage/indexability; for
times, choose datetime2 or
datetimeoffset deliberately and define precision
where comparisons or external interchange depend on it.
var e = modelBuilder.Entity<SqlServerWorkOrder>();e.Property(x => x.WorkOrderNumber).HasMaxLength(40).IsUnicode(false);e.Property(x => x.CustomerName).HasMaxLength(200).IsUnicode();e.Property(x => x.EstimatedCost).HasPrecision(12, 2);e.Property(x => x.OpenedUtc).HasColumnType("datetimeoffset(3)");e.Property(x => x.ClosedUtc).HasColumnType("datetime2(3)");
| C# intent | Typical SQL Server storage | Risk if left implicit |
|---|---|---|
| string customer name | nvarchar(n) | Unbounded/max storage and weak index design |
| decimal cost | decimal(p,s) | Unexpected rounding/truncation contract |
| DateTime UTC instant | datetime2(p) | Kind is not persisted by SQL Server |
| DateTimeOffset instant+offset | datetimeoffset(p) | Larger storage; semantics differ from DateTime |
7. Defaults and computed columns run in the database
A default is used when an INSERT does not provide a value. A computed column derives its value from other columns. These are store behaviors, so EF model configuration must match the actual DDL and value-generation metadata.
e.Property(x => x.CreatedUtc) .HasDefaultValueSql("SYSUTCDATETIME()");e.Property(x => x.SearchLabel) .HasComputedColumnSql( "CONCAT([WorkOrderNumber], N' · ', [CustomerName])", stored: true);
[CreatedUtc] datetime2 NOT NULL DEFAULT (SYSUTCDATETIME()),[SearchLabel] AS (CONCAT([WorkOrderNumber],N' · ',[CustomerName])) PERSISTED
Do not set a computed property as though it were ordinary
writable state. Verify ValueGenerated metadata and
inspect INSERT/UPDATE commands to ensure EF is not sending a
value the store owns.
8. Collation decides text equality and ordering—and can decide index usage
SQL Server installations are commonly case-insensitive by
default, while .NET string equality is case-sensitive. EF
translates simple string equality to SQL equality and therefore
inherits the database/column collation. If the domain requires a
particular rule, configure it at database or column scope.
Query-level EF.Functions.Collate is an escape hatch
but may prevent use of an index whose collation differs.
e.Property(x => x.WorkOrderNumber) .UseCollation("Latin1_General_100_BIN2");
var exact = await db.WorkOrders .Where(w => EF.Functions.Collate( w.CustomerName, "SQL_Latin1_General_CP1_CS_AS") == customer) .ToListAsync(ct);
A function around an indexed column can change sargability. If case rules are stable, encode them in the column/database collation and verify the actual execution plan.
9. Failure case: carrying explicit SQLite IDs into an IDENTITY table
A data-import script materializes old rows, copies their integer
IDs, and calls Add. SQL Server rejects the insert
because explicit values cannot normally be inserted into an
identity column.
SqlException: Cannot insert explicit value for identity column in table'work_orders' when IDENTITY_INSERT is set to OFF.
Repair: for ordinary application inserts, leave
the identity key unset and preserve external/business identity
in WorkOrderNumber. If a one-time migration must
preserve physical IDs, design a reviewed data-migration step
that scopes SET IDENTITY_INSERT ... ON/OFF,
validates uniqueness/foreign keys, and never becomes normal
application behavior.
10. Mandatory lab: inspect model → migration → INSERT → returned values
-
Create a disposable SQL Server 2025 Developer database such as
ServiceHubSqlServerLab; never point these exercises at production. -
Build
SqlServerServiceHubContextwith identity, explicit facets, a store default, a persisted computed column, andVersion.IsRowVersion(). -
Add a migration named
SqlServerProviderBaselineand inspect both C# operations and generated SQL before applying it. - Insert one work order without setting the identity, computed value, default timestamp, or rowversion.
-
After
SaveChangesAsync, printId,CreatedUtc,SearchLabel, and the hex rowversion; inspect EF command logs for returned store values. - Update the row from two contexts to re-confirm the concurrency predicate and conflict behavior learned in Chapter 13.
-
Query
sys.columns,sys.default_constraints, andsys.indexesto verify the live database rather than relying only on EF metadata.
Drop only the disposable
ServiceHubSqlServerLab database or remove the
local container volume used for this chapter. Keep Chapter
01–18 SQLite artifacts untouched.
11. Production judgment and bridge
SQL Server-specific value generation and type mapping are appropriate when the provider is a deliberate architectural dependency. Pin Microsoft EF packages at 10.0.11, record Microsoft.Data.SqlClient/server/compatibility levels, and review migrations for generated-value or collation changes. Rowversion protects a row from lost updates but does not replace aggregate/business concurrency policy. Collations and type facets can affect indexes and query results. The next lesson adds temporal history and JSON—features where server version and compatibility level become even more visible.
Check your understanding
- Why is rowversion not a portable replacement for ServiceHub Revision?
- When does SQL Server use IDENTITY for EF numeric generated keys?
- What problem can HiLo reduce?
- Why must decimal precision be explicit?
- Why can query-level COLLATE hurt performance?
- What should you inspect after adding provider-specific mappings?
Review the answers
1. It is a SQL Server-generated binary concurrency mechanism with no universal provider equivalent; the course Guid token remains the portable path.
2. By convention for numeric properties generated on add, unless another SQL Server value-generation strategy is configured.
3. Repeated database round trips solely to allocate numeric keys, at the cost of gaps and provider/model complexity.
4. The CLR decimal type does not encode the database precision/scale contract; rounding or overflow behavior is a data rule.
5. A collation different from the indexed column can make the existing index ineligible for that comparison.
6. Runtime model metadata, migration operations/SQL, generated DML and returned store values, plus the live SQL Server catalog.
Authoritative references
These features are provider- and server-sensitive; re-check the SQL Server provider and engine documentation before changing a production model.
- SQL Server value generation - EF Core — IDENTITY, sequences, HiLo and rowversion
- SQL Server columns - EF Core — Unicode/UTF-8 and sparse-column provider features
- Collations and case sensitivity - EF Core — database/column/query collation and index implications
- Generated values - EF Core — defaults, computed columns and value-generation metadata
- Concurrency conflicts - EF Core — concurrency-token predicates and conflict resolution
- SQL Server provider - EF Core — provider configuration, compatibility and Azure SQL distinctions