Summit Sports Catering
Walk-in freezer
temperature alarm
Spatial Data in .NET
SPATIAL DATA IN .NET
Store the location, not just the coordinates.
Tim Purdum · dymaptic
TechBash 2026
A repair company near Scranton dispatches technicians to customer jobs.
Walk-in freezer
temperature alarm
Covers this address.
Available now.
Closest to the job.
SQL Server can find what is inside an area and measure how far away it is.
Mature, stable, and already in the database you use.
geometry and geography column types
Whole-globe shapes, AUTO_GRID spatial indexes, 48-bit precision
DbGeography, with SQL Server's types installed separately
NetTopologySuite Point and Polygon in your model
No spatial changes in the release notes since EF Core 7 in 2022
If you run SQL Server, you already have spatial types.
geographyLocations on Earth
Distance in meters with SRID 4326
geometryFloor plans and local grids
Distance in coordinate units
SRID 4326 identifies WGS 84, our longitude-and-latitude reference.
NetTopologySuite provides the C# spatial types.
public Point Location { get; set; }
public Polygon Boundary { get; set; }NETTOPOLOGYSUITEoptions.UseSqlServer(
connectionString,
sql => sql.UseNetTopologySuite());EF CORELocation geography NOT NULL
Boundary geography NOT NULLSQL SERVERGeocode addresses as they are entered into your database.
235 Montage Mountain Road
Moosic, Pennsylvania
lon -75.683967
lat 41.360406new Point(-75.683967,
41.360406)
{ SRID = 4326 }geography
POINT (-75.683967
41.360406)One customer record: an address becomes a queryable point.
Draw your own areas, or import boundaries you already have.
Four vertices on a map
POLYGON ((-75.830000 41.352000,
-75.610000 41.352000,
-75.585000 41.500000,
-75.835000 41.500000,
-75.830000 41.352000))Boundary geography · SRID 4326t.Boundary
.Contains(job.Location)The same shape every containment query tests
Drawn or imported, it becomes one stored ring.
NetTopologySuite: new Point(longitude, latitude)
new Point(41.4, -75.7)new Point(-75.7, 41.4)EF Core translates Contains into a SQL spatial test.
List<TerritoryEntity> containingTerritories =
await db.Territories
.Where(t => t.Boundary.Contains(job.Location))
.ToListAsync(cancellationToken);
TerritoryEntity? territory =
containingTerritories.SingleOrDefault();SELECT [t].[Id], [t].[Name], [t].[Boundary]
FROM [Territories] AS [t]
WHERE [t].[Boundary].STContains(
@jobLocation) = CAST(1 AS bit);The job is in North Valley.
The seed data avoids all three situations.
No match: the customer is outside coverage.
Two matches: an explicit priority, or a review.
A deliberate boundary and ownership rule.
The query reports what the shapes are. Your application decides what that means.
Filter by territory and availability. Then order by distance.
db.Technicians
.Where(t => t.TerritoryId == territory.Id
&& t.IsAvailable)
.OrderBy(t => t.Location.Distance(job.Location))
.FirstOrDefaultAsync();SELECT TOP(1) [t].[Id], [t].[Name], [t].[TerritoryId]
FROM [Technicians] AS [t]
WHERE [t].[TerritoryId] = @territory_Id
AND [t].[IsAvailable] = CAST(1 AS bit)
ORDER BY [t].[Location].STDistance(@job_Location);Predict the work before the next slide reveals it.
db.Technicians
.Where(t => t.TerritoryId == territory.Id
&& t.IsAvailable)
.OrderBy(t => t.Location.Distance(job.Location))
.FirstOrDefaultAsync();Prepared plan: 200,000 technicians
CREATE SPATIAL INDEX SIX_Territories_Boundary
ON Territories(Boundary)
USING GEOGRAPHY_AUTO_GRID;45.3 km covers this company's service area.
GeoJSONTerritory boundaries
Technician locations
JSONNorth Valley
Jordan Alvarez
5,688 meters
Where to draw
What to label
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-75.683967, 41.360406]
},
"properties": { "name": "Summit Sports Catering" }
}Coordinates use WGS 84, longitude first.
One Minimal API endpoint turns EF Core rows into GeoJSON features.
Api/MapLayerEndpoints.cslayers.MapGet("/territories", async (DispatchDbContext db,
CancellationToken cancellationToken) =>
{
List<Territory> territories = await db.Territories
.OrderBy(t => t.Id)
.ToListAsync(cancellationToken);
return MapLayerFeatures.ForTerritories(territories);
});MapLayerFeatures.csAttributesTable attributes = new()
{
{ "TerritoryId", territory.Id },
{ "Name", territory.Name }
};
collection.Add(
new Feature(territory.Boundary, attributes));Program.csbuilder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(
new GeoJsonConverterFactory()));GET /api/layers/territories{ "type": "FeatureCollection",
"features": [{
"type": "Feature",
"geometry": { "type": "Polygon",
"coordinates": [[[-75.83, 41.352], …]] },
"properties": { "TerritoryId": 1,
"Name": "North Valley" } },
… ] }TerritoryId is a contract: the map's highlight filters on it.
await db.Territories
.Where(t => t.Boundary
.Intersects(view))
.ToListAsync();TopologyPreservingSimplifier
.Simplify(boundary, meters);GeoBlazor adds maps and layers to Blazor.
<GeoJSONLayer Url="@LayerSource.TerritoriesUrl" />
<GeoJSONLayer Url="@LayerSource.TechniciansUrl" />await graphicsLayer.Add(new Graphic(
new Point(job.Longitude, job.Latitude),
new SimpleMarkerSymbol(JobFill, 18)));<GeoJSONLayer Url="@LayerSource.TerritoriesUrl"
DefinitionExpression="@($"TerritoryId = ${id}")"
Opacity="0.55" />await graphicsLayer.Add(new Graphic(
new Polyline([new MapPath(path)]),
new SimpleLineSymbol(RouteColor, 4)));
await mapView.GoTo([routeGraphic]);This demo selects a technician first, then requests a route.
Geographic distance
Jordan · 5.7 km
ArcGIS routing services answer the questions straight-line distance cannot.
Ranks up to 10 technicians per job by travel time, in one direct request.
Returns drive-time polygons, also called isochrones. GeoBlazor Pro wraps it as ServiceAreaService.
Assigns a day's jobs to a fleet and orders each van's stops: the traveling salesman, solved as a service.
Let SQL Server shortlist the candidates. Pay for drive times on the shortlist.
PostGIS answers the same territory and nearest-technician questions.
options.UseSqlServer(
connectionString,
sql => sql.UseNetTopologySuite());Boundary.STContains(job)
meters via geography
options.UseNpgsql(
connectionString,
sql => sql.UseNetTopologySuite());ST_Contains(Boundary, job)
same NetTopologySuite LINQ
Carries over: the C# model, the LINQ shape, the GeoJSON pipeline.
Check first: PostGIS defaults to geometry — opt into geography for meter distances. Index with GiST. Confirm translation coverage.
Other engines: Azure SQL Database runs the SQL Server engine. SQLite stores shapes but translates few spatial methods.
The company keeps its boundaries in a GIS and serves them.
Boundaries and locations your app reads again and again. The dashboard's GeoJSON endpoint is one of these.
Geocoding and routing, called per request — like this demo's single route call.
The dashboard already uses layers, graphics, selection, and events.
<MapView Longitude="@ViewLongitude"
Latitude="@ViewLatitude" Zoom="@ViewZoom">
<Map>
<Basemap>
<OpenStreetMapLayer />
</Basemap>
<GeoJSONLayer Url="@LayerSource.TerritoriesUrl"
Title="Service territories" />
<GeoJSONLayer Url="@LayerSource.TechniciansUrl"
Title="Technicians" />
<GraphicsLayer @ref="_graphicsLayer" Title="Dispatch" />
</Map>
</MapView>MapView with a GeoJSONLayer per data setGraphicsLayer draws the job, technician, and routeA published ArcGIS feature service can feed the same layers.
SQL geography
C# Point / Polygon
Contains / Distance
GeoJSON + result
Blazor map
Longitude first for our Point and GeoJSON.
Meters for this geography data.
Check the generated query.
Inspect the execution plan.
Questions
timpurdum.dev/post/2026/10/15/techbash.html
Two minutes, and it decides whether this talk runs again.
Sample app: github.com/dymaptic/GeoBlazor-Samples · projects/SpatialDispatch
GeoBlazor-Samples · projects/SpatialDispatch
SpatialDispatch/
appsettings.Development.json.template
SpatialDispatch.Client/wwwroot/
appsettings.Development.json.templateCopy each template without .template. The host's copy holds the connection string; the client's holds GeoBlazor:LicenseKey. Both are gitignored.
cd projects/SpatialDispatch
docker compose up -d
dotnet run --project SpatialDispatchStartup waits for SQL Server, applies the migration, and seeds the data.
Needs: the .NET 10 SDK, Docker, and a GeoBlazor Pro license key. A free Core key won't load Pro.
Without a key: the map asks for one, but the dispatch answer, the candidates, and assignment still work.
Optional: on Windows, set ConnectionStrings__DispatchDatabase to LocalDB instead of running the container. An ArcGISApiKey adds road routing; without it, a dashed connector.
Scratch data behind the plans on slides 14 and 16
WHERE [t].[Location].STDistance(@jobLocation)
<= 45269
ORDER BY [t].[Location].STDistance(@jobLocation)STDistance in WHERE and the first ORDER BY
200,000 → 13,211 rows read
List<TerritoryEntity> matches = await db.Territories
.Where(t => t.Boundary.Contains(job.Location))
.ToListAsync(cancellationToken);
TerritoryEntity? territory = matches.SingleOrDefault();SingleOrDefaultAsync emits TOP(2), which chose a scan
15,918 → 24 rows read
Scratch data: 200,000 technician points and 40,000 territory polygons, with the real columns, clustered primary keys, and GEOGRAPHY_AUTO_GRID indexes.
Setup traps: no clustered primary key raises error 12008. Script with sqlcmd -I, or CREATE SPATIAL INDEX fails with error 1934.
Your data: 45 km is the diagonal of this company's territories, not a general rule. Measure at your own row counts.