Building a Custom Domain¶
When to use this¶
Use the custom domain pattern when:
- None of the existing modules (Jobs, TimeSeries, GIS, Scalars, ...) fit your data model.
- You want a project-specific domain that integrates cleanly into the DS ecosystem (same
connectionIdrouting, sameconnections.jsonconfiguration, same tooling). - You are building a reusable component that others in DHI can consume via NuGet.
Step 1 — Define your Entity¶
Choose a base class based on what fields you need:
| Need | Base class |
|---|---|
| ID only | BaseEntity<TId> |
| ID + Name | BaseNamedEntity<TId> |
| ID + Name + Group | BaseGroupedEntity<TId> |
| ID is a file path | Compose an ID type from BaseGroupedFileEntityId |
Example — a Sensor with an ID, name, and unit:
using DHI.Services;
public sealed class Sensor : BaseNamedEntity<Guid>
{
public Sensor(Guid id, string name, string unit)
: base(Guard.Against.Null(id, nameof(id)),
Guard.Against.NullOrWhiteSpace(name, nameof(name)))
{
Unit = Guard.Against.NullOrWhiteSpace(unit, nameof(unit));
}
public string Unit { get; }
}
Tip: Use
Guard.Againstin constructors to enforce invariants up front. See Core Concepts for all available guards.
Step 2 — Create the Repository¶
Define an interface and a JSON-backed implementation:
using DHI.Services;
public interface ISensorRepository :
IUpdatableRepository<Sensor, Guid>,
IDiscreteRepository<Sensor, Guid>
{ }
public sealed class SensorRepository : JsonRepository<Sensor, Guid>, ISensorRepository
{
public SensorRepository(string filePath) : base(filePath) { }
}
- Implement
IDiscreteRepositoryif you needGetAll/Count. - Implement
IUpdatableRepositoryif you needAdd/Update/Remove. - For grouping, also implement
IGroupedRepository<Sensor>andIGroupedUpdatableRepository.
For production, replace the JSON base class with a provider-specific implementation (PostgreSQL, etc.) that implements the same interfaces.
Step 3 — Create the Service¶
using DHI.Services;
using Microsoft.Extensions.Logging;
public interface ISensorService :
IUpdatableService<Sensor, Guid>,
IDiscreteService<Sensor, Guid>
{ }
public sealed class SensorService
: BaseUpdatableDiscreteService<Sensor, Guid>, ISensorService
{
public SensorService(ISensorRepository repo, ILogger<SensorService> logger = null)
: base(repo, logger) { }
// Add custom business logic, validation, or derived queries here
}
The base class handles Add, Update, Remove, GetAll, Count, Exists, TryGet, and automatically stamps Added/Updated timestamps on entities that implement ITraceableEntity<TId>.
Step 4 — Register and Use (Imperative)¶
var repo = new SensorRepository("[AppData]sensors.json".Resolve());
var service = new SensorService(repo);
ServiceLocator.Register(service, "sensors-json");
// Later:
var svc = ServiceLocator.Get<ISensorService>("sensors-json");
var sensor = new Sensor(Guid.NewGuid(), "CTD #1", "mS/cm");
svc.Add(sensor);
if (svc.TryGet(sensor.Id, out var found))
Console.WriteLine($"{found.Name} — {found.Unit}");
Step 5 — Register via connections.json (Declarative)¶
If you have (or plan to create) a SensorServiceConnection class, add a connections.json entry:
{
"$type": "System.Collections.Generic.Dictionary`2[[System.String, mscorlib],[DHI.Services.IConnection, DHI.Services]], mscorlib",
"sensors-json": {
"$type": "MyCompany.Services.Sensors.WebApi.SensorServiceConnection, MyCompany.Services.Sensors.WebApi",
"ConnectionString": "[AppData]sensors.json",
"RepositoryType": "MyCompany.Services.Sensors.SensorRepository, MyCompany.Services.Sensors",
"Name": "Sensors (JSON)",
"Id": "sensors-json"
}
}
Note: Without a dedicated Web API package you won't have a
SensorServiceConnection. In that case, use imperative registration (Step 4) or write your own ASP.NET Core controller that resolvesServiceLocator.Get<ISensorService>("sensors-json").
Step 6 — Expose via a Custom Controller (Optional)¶
If you don't want to create a full Web API NuGet package, a minimal controller wires your service to HTTP:
using DHI.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/sensors/{connectionId}")]
[Authorize]
public class SensorsController : ControllerBase
{
[HttpGet]
public IActionResult GetAll(string connectionId)
{
var svc = ServiceLocator.Get<ISensorService>(connectionId);
return Ok(svc.GetAll(User));
}
[HttpGet("{id:guid}")]
public IActionResult GetById(string connectionId, Guid id)
{
var svc = ServiceLocator.Get<ISensorService>(connectionId);
return svc.TryGet(id, out var sensor) ? Ok(sensor) : NotFound();
}
[HttpPost]
public IActionResult Add(string connectionId, [FromBody] Sensor sensor)
{
var svc = ServiceLocator.Get<ISensorService>(connectionId);
svc.Add(sensor);
return CreatedAtAction(nameof(GetById), new { connectionId, id = sensor.Id }, sensor);
}
}
No try/catch needed — UseExceptionHandling*() middleware maps exceptions to HTTP status codes automatically.
Creating a Reusable Web API Package¶
When your domain is stable and used in multiple projects, extract it into a NuGet package following the built-in module pattern:
MyCompany.Services.Sensors— entity, repository interface, service,JsonRepositoryimplementation.MyCompany.Services.Sensors.WebApi— controller,SensorServiceConnection : IConnection,SerializerOptionsDefault.
The connection class implements IConnection.Create() to construct the service from a ConnectionString and RepositoryType:
public sealed class SensorServiceConnection : IConnection
{
public string Id { get; set; }
public string Name { get; set; }
public string ConnectionString { get; set; }
public string RepositoryType { get; set; }
public object Create()
{
var repoType = Type.GetType(RepositoryType)
?? throw new InvalidOperationException($"Type not found: {RepositoryType}");
var repo = (ISensorRepository)Activator.CreateInstance(repoType, ConnectionString.Resolve());
return new SensorService(repo);
}
}
Once published, other teams can add this package and use connections.json to configure it — exactly like DHI.Services.Jobs.WebApi or DHI.Services.TimeSeries.WebApi.