Skip to content

DHI.Services.GIS — Internal Developer Guide

This module is the GIS backbone of Domain Services. It gives you a consistent, provider-agnostic way to read, query, transform (reproject), and—when supported, update vector datasets called Feature Collections. You plug in a repository (e.g., PostgreSQL, files, cloud, etc.), and the service layer exposes a stable API your app and Web APIs can use.


Why use this module?

  • Uniform API across providers: read one, switch providers later with no code changes.
  • Strong typing & guardrails: Maybe<T> at the repo layer; exceptions at the service layer for clean error handling.
  • Filtering & projection: attribute filters via QueryCondition; reprojection via outSpatialReference.
  • Geometry utilities: envelopes, footprints, and raw GeometryCollection access.
  • Updatable workflows (optional): add/update/remove features and attributes with safe checks.

Mental model

GIS Module Flow

  • Id types are usually string for feature collections; feature IDs are often Guid.
  • Groups let you organize collections hierarchically (e.g., Basins/Nile/Stations).

Core types you’ll work with

  • FeatureCollection<TId> — a named/grouped collection with Features, Attributes (schema), and metadata.
  • FeatureCollectionInfo<TId> — metadata + schema only (no feature geometries/values).
  • IGeometry, GeometryCollection — spatial types (from DHI.Services.Spatial).
  • QueryCondition / QueryOperator — attribute filtering primitives (from DHI.Services).
  • Maybe<T> — “value or nothing”. Repositories return Maybe<T>; services throw if nothing is found (cleaner for app code).

What you can do

Read (every repo)

  • Get a collection (optionally with associations, reprojection)
  • Get schema/info for a collection
  • Get only the geometries
  • Get envelope (bbox) and footprint (union outline + optional simplification)
  • Enumerate IDs by filter and list geometry types per collection
  • (Optional) stream a serialized data format (if the provider supports GetStream)

Group-aware reads

  • Test if a group exists
  • List collections by group
  • List full names (Group/Name) and geometry types by group
  • Get Info for all collections in a group

Updates (only updatable repos)

  • Add/Update/Remove feature collections
  • Add/Update/Remove features
  • Batch updates to attribute values by feature IDs or by a filter
  • Add/Update/Remove attributes (schema)
  • Add/Remove feature associations (relations)

Wiring it up

1) Register a connection (read-only, single collection id type)

using DHI.Services;
using DHI.Services.GIS;

// Example: provider fully-qualified name in a config string/JSON
var repoType = "DHI.Services.Provider.ShapeFile.FeatureRepository, DHI.Services.Provider.ShapeFile";
// e.g., connection string or any provider-specific parameter string
var connString = "[AppData]shp";

// Construct via the connection helper (reflection + DI friendly)
var connection = new GisServiceConnection<string>("gis:prod", "Main GIS")
{
    RepositoryType = repoType,
    ConnectionString = connString
};

var gisService = (GisService<string>) connection.Create();
ServiceLocator.Register(gisService, "gis-prod");  // connectionId used by your app/frontend

2) Register a grouped connection

connString = "database=mc2014.2";
var grouped = new GroupedGisServiceConnection<string>("gis:groups", "GIS (Grouped)")
{
    RepositoryType = "DHI.Services.Provider.MCLite.FeatureRepository, DHI.Services.Provider.MCLite",
    ConnectionString = connString
};

var groupedService = (GroupedGisService<string>) grouped.Create();
ServiceLocator.Register(groupedService, "gis-groups");

3) Register an updatable connection

connString = "database=[AppData]MCSQLiteTest.sqlite;dbflavour=SQLite";
var updatable = new UpdatableGisServiceConnection<string, Guid>("gis:edit", "GIS (Editable)")
{
    RepositoryType = "DHI.Services.Provider.MCLite.FeatureRepository, DHI.Services.Provider.MCLite",
    ConnectionString = connString
};

var editService = (UpdatableGisService<string, Guid>) updatable.Create();
ServiceLocator.Register(editService, "gis-edit");

Discovering providers at runtime If you inject ConnectionTypeService(AppContext.BaseDirectory) (like in Documents), the connection type helpers (e.g., GisServiceConnection<T>.CreateConnectionType<TConnection>()) will scan assemblies (by default names starting with DHI.Services) for compatible repositories so your UI/config tooling can present a drop-down of provider implementations.


Using the services

Assume you’ve retrieved a service with:

var gis = Services.Get<GisService<string>>("gis-prod");

Get a collection

// associations: include provider-specific linked data if available
// outSpatialReference: e.g., "EPSG:3857" or provider-specific string (WKT ok).
var fc = gis.Get("Basins/Nile/Stations", associations: false, outSpatialReference: "EPSG:4326");

Get just schema/metadata

var info = gis.GetInfo("Basins/Nile/Stations");
foreach (var a in info.Attributes)
{
    Console.WriteLine($"{a.Name} ({a.DataType})");
}

Filter by attributes

using DHI.Services; // QueryCondition, QueryOperator

var filter = new[]
{
    new QueryCondition("station_type", QueryOperator.Equal, "gauging"),
    new QueryCondition("discharge", QueryOperator.GreaterThanOrEqual, 1000)
};

// Get a filtered collection (geometry + attributes of matching features)
var fcFiltered = gis.Get("Basins/Nile/Stations", filter, associations:false, outSpatialReference:"EPSG:4326");

// Or: just the geometries (faster if you don't need attribute values)
var geoms = gis.GetGeometry("Basins/Nile/Stations", filter, "EPSG:4326");

The service validates filter attribute names via ContainsAttribute and will throw if you reference a non-existing attribute.

Envelopes & footprints

var envelope = gis.GetEnvelope("Basins/Nile/Stations", "EPSG:3857");
var footprint = gis.GetFootprint("Basins/Nile/Stations", "EPSG:3857", simplifyDistance: 1000); // meters or provider units

Enumerate IDs that match a query

var ids = gis.GetIds(new[] { new QueryCondition("country", QueryOperator.Equal, "EG") });

Geometry types present per collection

var types = gis.GetGeometryTypes();
foreach (var (fullName, geomTypes) in types)
{
    Console.WriteLine($"{fullName}: {string.Join(", ", geomTypes)}");
}

Stream a collection (provider dependent)

// Some repos support exporting a collection as e.g. GeoPackage, zipped Shapefile, etc.
var (stream, fileType, fileName) = gis.GetStream("Basins/Nile/Stations");
// fileType is a short token such as "gpkg", "zip", “json” — provider dependent

Group-centric usage

If your repository is grouped, work with IGroupedGisService<TId>:

var grouped = Services.Get<GroupedGisService<string>>("gis:groups");

bool exists = grouped.ContainsGroup("Basins/Nile");
var list = grouped.GetByGroup("Basins/Nile");             // IEnumerable<FeatureCollection<string>>
var fullNames = grouped.GetFullNames("Basins/Nile");       // ["Basins/Nile/Stations", …]
var infos = grouped.GetInfo("Basins/Nile");                // IEnumerable<FeatureCollectionInfo<string>>
var typesByGroup = grouped.GetGeometryTypes("Basins/Nile");

Editing data (updatable repositories only)

Important: Feature ID must be available as the attribute named "id". For TFeatureId = Guid, UpdatableGisService converts the attribute to a Guid.

var edit = Services.Get<UpdatableGisService<string, Guid>>("gis-edit");

// Add a feature
var newFeature = FeatureFactory.Point(
    id: Guid.NewGuid(),
    x: 31.2, y: 30.1, srid: 4326,
    attributes: new Dictionary<string, object> { ["name"] = "New Station", ["id"] = Guid.NewGuid() }
);
edit.AddFeature("Basins/Nile/Stations", newFeature);

// Update a feature
var toUpdate = edit.GetFeature("Basins/Nile/Stations", someFeatureId, associations: true);
toUpdate.AttributeValues["name"] = "Renamed";
edit.UpdateFeature("Basins/Nile/Stations", toUpdate);

// Remove features
edit.RemoveFeature("Basins/Nile/Stations", someFeatureId);
edit.RemoveFeatures("Basins/Nile/Stations", new [] { id1, id2 });

// Attribute schema ops
edit.AddAttribute("Basins/Nile/Stations", AttributeFactory.String("owner", length: 80));
edit.RemoveAttribute("Basins/Nile/Stations", "deprecatedField");

// Attribute value updates (by id, ids, or filter)
edit.UpdateAttributeValue("Basins/Nile/Stations", someFeatureId, "owner", "DHI");
edit.UpdateAttributeValues("Basins/Nile/Stations", new[]{ id1, id2 }, "owner", "DHI");
edit.UpdateAttributeValue("Basins/Nile/Stations",
    filter: new[]{ new QueryCondition("country", QueryOperator.Equal, "EG") },
    attributeName: "owner", value: "MWRI");

// Associations
edit.AddAssociation("Basins/Nile/Stations", someFeatureId, AssociationFactory.RelatesTo("Documents/…"));
edit.RemoveAssociation("Basins/Nile/Stations", someFeatureId, AssociationFactory.RelatesTo("Documents/…"));

Errors you’ll see (thrown by services):

  • KeyNotFoundException — collection/feature/attribute not found.
  • ArgumentException — invalid inputs, e.g., missing "id" on a feature you try to add/update.

Repositories use Maybe<T> internally; services standardize this into exceptions for consumers.


Filtering cheat-sheet (QueryCondition)

You build filters with items (attribute names), operators, and values:

new QueryCondition("discharge", QueryOperator.GreaterThan, 1000);
new QueryCondition("station_type", QueryOperator.Like, "%gauging%");
new QueryCondition("country", QueryOperator.Any, new [] { "EG", "SD", "ET" });
new QueryCondition("geometry", QueryOperator.SpatiallyWithinDistance, new { wkt="POINT(…)", distance=1000 }); // provider-specific support

Spatial operators (Intersects, Contains, SpatiallyWithin*, etc.) are defined in QueryOperator, but availability depends on the provider.


Reprojection (outSpatialReference)

Almost every read method can take outSpatialReference. Common values are the literal EPSG:xxxx or a WKT string; the exact forms accepted are provider-specific. If reprojection is not supported, the repo may ignore the parameter or throw an error.


Streaming export (GetStream)

IGisRepository<TId>.GetStream is optional. If your provider implements it, you’ll get (Maybe<Stream>, fileType, fileName). Typical fileType values might be "zip", "gpkg", "json". Use the tuple to set HTTP content types in your Web API.

If not supported, the base repository throws NotSupportedException.


Dynamic file patterns (utility)

DynamicFileSource lets you compute a file path from date/time and parameters, with optional choice lists:

var src = new DynamicFileSource(
  @"C:\tiles\?yyyy\\MM\\dd?\?hour|0:6:HH|6:18:HH|18:23:HH?\layer_""level"".png:layer_01|layer_02:"
);

// At runtime:
var filePath = src.GetFile(DateTime.UtcNow, new Parameters { ["level"] = "01" });
// - Replaces ?…? with DateTime.ToString(format) (special “hour” rule chooses a format by time range)
// - Replaces "paramName" with parameters[paramName]
// - If a :choiceA|choiceB: segment exists, picks the first existing file

Great for file-backed vector/rasters that roll over hourly/daily.


Helpful extensions (misc)

  • SKColor ToColor(string) — parses “#RRGGBB”, “rgba(…)”, or named colors via SkiaSharp.SKColors.
  • Bitmap.CropAndResize(SKRectI src, SKRectI dst) — quick graphics helper for map/raster generation.
  • IEnumerable<Tile>.GetEnvelope() — compute tile set bbox.
  • DateTime.ToIdStandard() -> "yyyyMMdd_HHmmss".

Example: expose your GIS via ASP.NET Core

Even without a dedicated WebApi package, you can expose endpoints quickly:

[ApiController]
[Route("api/gis/{connectionId}")]
public class GisController : ControllerBase
{
    [HttpGet("{id}/info")]
    public ActionResult<FeatureCollectionInfo<string>> Info(string connectionId, string id)
    {
        var service = Services.Get<GisService<string>>(connectionId);
        return Ok(service.GetInfo(id));
    }

    [HttpGet("{id}")]
    public ActionResult<FeatureCollection<string>> Get(string connectionId, string id, [FromQuery] string sref = null)
    {
        var service = Services.Get<GisService<string>>(connectionId);
        return Ok(service.Get(id, associations:false, outSpatialReference:sref));
    }

    [HttpPost("{id}/query")]
    public ActionResult<GeometryCollection> QueryGeom(string connectionId, string id, [FromBody] QueryDTO dto, [FromQuery] string sref = null)
    {
        var service = Services.Get<GisService<string>>(connectionId);
        return Ok(service.GetGeometry(id, dto.ToQueryConditions(), sref));
    }

    [HttpGet("{id}/stream")]
    public IActionResult Stream(string connectionId, string id)
    {
        var service = Services.Get<GisService<string>>(connectionId);
        var (stream, fileType, fileName) = service.GetStream(id);
        stream.Seek(0, SeekOrigin.Begin);
        var contentType = fileType?.ToLowerInvariant() switch
        {
            "zip" => "application/zip",
            "gpkg" => "application/geopackage+sqlite3",
            "json" => "application/json",
            _ => "application/octet-stream"
        };
        return File(stream, contentType, fileName ?? $"{id}.{fileType}");
    }
}

connections.json examples (GIS)

If you prefer the same pattern you used for Documents:

[
  {
    "type": "DHI.Services.GIS.WebApi.GisServiceConnection, DHI.Services.GIS.WebApi",
    "id": "gis-prod",
    "name": "GIS (Read-only)",
    "repositoryType": "DHI.Services.Provider.ShapeFile.FeatureRepository, DHI.Services.Provider.ShapeFile",
    "connectionString": "[AppData]shp"
  },
  {
    "type": "DHI.Services.GIS.WebApi.GroupedGisServiceConnection, DHI.Services.GIS.WebApi",
    "id": "gis-groups",
    "name": "GIS (Grouped)",
    "repositoryType": "DHI.Services.Provider.MCLite.FeatureRepository, DHI.Services.Provider.MCLite",
    "connectionString": "database=mc2014.2"
  },
  {
    "type": "DHI.Services.GIS.WebApi.GroupedUpdatableGisServiceConnection, DHI.Services.GIS.WebApi",
    "id": "gis-edit",
    "name": "GIS (Editable)",
    "repositoryType": "DHI.Services.Provider.MCLite.FeatureRepository, DHI.Services.Provider.MCLite",
    "connectionString": "database=[AppData]MCSQLiteTest.sqlite;dbflavour=SQLite"
  }
]

Loader sketch (same idea as in Documents):

foreach (var e in entries)
{
    object service = e.type switch
    {
        "GisServiceConnection" =>
            (GisService<string>) new GisServiceConnection<string>(e.id, e.name)
            { RepositoryType = e.repositoryType, ConnectionString = e.connectionString }.Create(),
        "GroupedGisServiceConnection" =>
            (GroupedGisService<string>) new GroupedGisServiceConnection<string>(e.id, e.name)
            { RepositoryType = e.repositoryType, ConnectionString = e.connectionString }.Create(),
        "UpdatableGisServiceConnection" =>
            (UpdatableGisService<string, Guid>) new UpdatableGisServiceConnection<string, Guid>(e.id, e.name)
            { RepositoryType = e.repositoryType, ConnectionString = e.connectionString }.Create(),
        _ => throw new NotSupportedException(e.type)
    };

    ServiceLocator.Register(service, e.id);
}

Practical tips

  • Attribute "id" is mandatory for editing. For Guid features, make sure it’s parsable as a GUID.
  • Attribute validation happens before filtered geometry queries; typo’d attribute names lead to KeyNotFoundException.
  • Spatial reference strings vary by provider. Prefer "EPSG:xxxx" when in doubt.
  • Streaming is provider-specific. If your downloads show application/octet-stream, set your content types from the returned fileType.
  • Maybe vs exceptions: Repository APIs return Maybe<T>; service APIs throw. When building your own repos, adhere to this pattern (don’t throw for “not found”, return Maybe.Empty).
  • Groups: Use forward slashes (Group/Sub/Name). If you mirror a documents pattern, remember that Web routing often uses | and is decoded back to / by helpers (FullNameString in WebApiCore).

When to choose which service

  • Use GisService<TId> if you just need reads over a flat namespace of feature collections.
  • Use GroupedGisService<TId> if your collections are organized hierarchically and you want group queries.
  • Use UpdatableGisService<TCollectionId, TFeatureId> when your store supports edits and you want safe, high-level operations.