Domain Services Core (DHI.Services)¶
What is this?¶
DHI.Services is a small but opinionated toolkit for building domain modules in .NET:
- Entities (pure models with metadata, security, audit fields)
- Repositories (data access abstractions that can be JSON-file based or provider-backed like PostgreSQL/USGS/etc.)
- Services (application logic that sits on top of repositories, with lifecycle events)
- Registration utilities (
ServiceLocator,Services.Configure+ connections)
You compose these like LEGO: define your entity -> pick a repository flavour -> inherit a service base class -> register -> (optionally) expose through a Web API package.
Core concepts at a glance¶
Entities (your models)¶
Choose one of these base types:
| Base type | Use when… | Gives you |
|---|---|---|
BaseEntity<TId> |
You only need an ID + metadata + permissions + audit fields. | Id, Metadata, Permissions, Added/Updated, IsAllowed(…), Clone<T>(). |
BaseNamedEntity<TId> |
Your entity also has a logical Name. | Everything in BaseEntity + Name. |
BaseGroupedEntity<TId> |
Your entities live in logical groups (e.g., Project/Station/Folder). | Everything above + Group and FullName = "{Group}/{Name}". |
BaseGroupedFileEntityId (ID helper) |
Your ID is a path-like file identifier (plus optional object id). | RelativeFilePath, Group, FileName, Name, FullName. Great when a file (dfs0/geojson/etc.) is the thing. |
Supporting interfaces:
IEntity<TId>->Id,MetadataITraceableEntity<TId>->Added,UpdatedISecuredEntity<TId>->PermissionsandIsAllowed(…)INamedEntity<TId>->NameIGroupedEntity<TId>->Group,FullName
Security note:
IsAllowedhonours deny over allow. If any matching permission isDenied, the operation is rejected even if another rule allows it.
Repositories (data access)¶
Pick based on how you load/persist:
| Base repository | Implement / Use when… | Notes |
|---|---|---|
BaseDiscreteRepository<TEntity,TId> |
You can enumerate all entities and filter in memory. | You implement GetAll(ClaimsPrincipal?). Everything else is derived. |
ImmutableJsonRepository<TEntity,TId> |
JSON file persistence, add/remove support, no in-place update. | Thread-safe, basic query by LINQ expression. |
JsonRepository<TEntity,TId> |
JSON file persistence with update support. | Extends ImmutableJsonRepository to add Update(…). |
BaseJsonRepository{Default} |
Lower-level utilities that handle JSON (de)serialization + caching. | You usually don’t inherit these directly unless you’re building a custom JSON repo. |
| Grouped helpers | Add IGroupedRepository<TEntity> / IGroupedUpdatableRepository when grouping is a first-class concern. |
Enables GetByGroup, RemoveByGroup, etc. |
| Streamable helpers | Implement IStreamableRepository<TId> if your entity can be returned as a file stream. |
Pair with IStreamableService<TId>. |
For production backends, you’ll typically use a provider package (e.g., PostgreSQL) that already implements the right repository interface(s).
Services (application logic)¶
Services wrap repositories and add guardrails + lifecycle events. Choose one:
| Base service | Use when… | Provides |
|---|---|---|
BaseService<TEntity,TId> |
Read-only access (single or batch read). | TryGet, logging, error handling. |
BaseDiscreteService<TEntity,TId> |
Read-only, but you also need GetAll/Count/Exists/GetIds. |
Delegates to IDiscreteRepository. |
BaseUpdatableService<TEntity,TId> |
You need Add/Update/Remove (no “list all”). |
Events: Adding/Added, Updating/Updated, Deleting/Deleted. Sets Added/Updated timestamps. |
BaseUpdatableDiscreteService<TEntity,TId> |
You need both updatable and discrete behaviors. | Combination of the above. |
BaseImmutableDiscreteService<TEntity,TId> |
Can add/remove but no in-place updates (append-only). | Useful for audit log or immutable stores. |
BaseGroupedUpdatableService / BaseGroupedUpdatableDiscreteService |
You need group-aware operations. | GetByGroup, RemoveByGroup, DeletedGroup/DeletingGroup events, etc. |
All updatable services automatically stamp
Added/Updatedon entities implementingITraceableEntity<TId>.
Utilities you’ll use¶
Maybe<T>— small optional wrapper returned by repositories. PreferTryGet(…)APIs in services over throwing.Permission— value type withPrincipals,Operation,Type (Allowed/Denied).ServiceLocator— in-memory registry keyed by serviceId/connectionId (used by Web APIs).Services.Configure— declarative service creation from a connections repository (see “registration” below).Service.GetProviderTypes<TAbstraction>(path?, pattern?)— plugin discovery helper that scans assemblies for types implementing a given interface (used by “providers”).
Choosing the right base types (cheat sheet)¶
Entity choice
- Need Id only ->
BaseEntity<TId> - Need Id + Name ->
BaseNamedEntity<TId> - Need Id + Name + Group ->
BaseGroupedEntity<TId> - Your ID is a file path -> compose an ID type from
BaseGroupedFileEntityIdand use that asTId
Repository choice
- JSON file, mutable ->
JsonRepository<TEntity,TId> - JSON file, append/remove only ->
ImmutableJsonRepository<TEntity,TId> - Non-file (PostgreSQL/remote API) -> implement
IDiscreteRepository/IUpdatableRepository(or use an existing provider)
Service choice
- Read-only list ->
BaseDiscreteService - Full CRUD + list ->
BaseUpdatableDiscreteService - Group-aware CRUD ->
BaseGroupedUpdatableDiscreteService - Append/remove only ->
BaseImmutableDiscreteService
Security & permissions in entities¶
Every BaseEntity<TId> carries Permissions: IList<Permission>. You can:
// allow “Editors” to update; deny “Guests” from reading
entity.AddPermission(new[] { "Editors" }, "update", PermissionType.Allowed);
entity.AddPermission(new[] { "Guests" }, "read", PermissionType.Denied);
// checks (deny wins)
var ok = entity.IsAllowed(user, "update"); // ClaimsPrincipal-based
var ok2 = entity.IsAllowed(new[] { "Editors" }, "update");
Wire principals however your app models them (e.g., roles, groups, tenant tags). The IsAllowed helpers intersect your provided principal set with each permission’s principals.
The Maybe<T> pattern (and why services expose TryGet)¶
Repositories return Maybe<TEntity>. Services turn that into easy, non-throwing TryGet:
if (myService.TryGet(id, out var entity))
{
// use entity
}
else
{
// not found (and logged via the service's logger)
}
This avoids exceptions in typical “not found” flows.
Lifecycle events (how to hook in)¶
Updatable services raise cancellable + informational events:
Adding(…)/Added(…)Updating(…)/Updated(…)Deleting(…)/Deleted(…)- Grouped variants:
DeletingGroup(…)/DeletedGroup(…)
Example: veto a delete in your app layer:
myService.Deleting += (_, e) =>
{
if (IsProtected(e.Item)) e.Cancel = true;
};
Registration patterns (imperative vs declarative)¶
A) Imperative (code)¶
Register your constructed service under a serviceId:
ServiceLocator.Register(myService, "orders-json");
var svc = ServiceLocator.Get<IOrdersService>("orders-json");
B) Declarative (connections)¶
Let a connections repository create services lazily from a connections.json file:
// once at startup:
Services.Configure(new ConnectionRepository("[AppData]connections.json".Resolve()),
lazyCreation: true);
// later at runtime:
var svc = Services.Get<IOrdersService>("orders-pg");
When you use the Web API packages, routes take {connectionId} which maps to these IDs.
End-to-end recipe: a minimal custom domain¶
Entity¶
public sealed class Sensor : BaseNamedEntity<Guid>
{
public Sensor(Guid id, string name, string unit)
: base(id, name)
{
Unit = unit ?? throw new ArgumentNullException(nameof(unit));
}
public string Unit { get; }
}
If you want project scoping, switch to
BaseGroupedEntity<Guid>and addgroupto ctor; you’ll getGroupandFullName.
Repository (JSON, updatable + discrete)¶
public interface ISensorRepository :
IUpdatableRepository<Sensor, Guid>,
IDiscreteRepository<Sensor, Guid>
{}
public sealed class SensorRepository : JsonRepository<Sensor, Guid>, ISensorRepository
{
public SensorRepository(string filePath) : base(filePath) { }
// Optional: override GetAll to apply ordering or filtering
public override IEnumerable<Sensor> GetAll(ClaimsPrincipal user = null) =>
base.GetAll(user).OrderBy(s => s.Name);
}
For production DBs, implement
ISensorRepositoryagainst your provider API instead of inheritingJsonRepository.
Service (CRUD + list)¶
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) { }
// Optional: additional business logic, guards, derived queries...
}
Register & use¶
Imperative
var repo = new SensorRepository("[AppData]sensors.json".Resolve());
var service = new SensorService(repo);
ServiceLocator.Register(service, "sensors-json");
var sensors = ServiceLocator.Get<ISensorService>("sensors-json");
var sensor = new Sensor(Guid.NewGuid(), "CTD #1", "mS/cm");
sensors.Add(sensor);
Declarative (connections.json)
If you ship a DHI.Services.Sensors.WebApi package (analogous to Jobs/TimeSeries), you’d add an entry like:
{
"$type": "System.Collections.Generic.Dictionary`2[[System.String, mscorlib],[DHI.Services.IConnection, DHI.Services]], mscorlib",
"sensors-json": {
"$type": "DHI.Services.Sensors.WebApi.SensorServiceConnection, DHI.Services.Sensors.WebApi",
"RepositoryType": "MyCompany.Services.Sensors.SensorRepository, MyCompany.Services.Sensors",
"ConnectionString": "[AppData]sensors.json",
"Name": "Sensors (JSON)",
"Id": "sensors-json"
}
}
Don’t have a Web API package for your domain yet? You can still use the service in-process or expose it from your own ASP.NET Core controllers.
Grouped & file-based IDs (when your data is on disk)¶
If each entity is backed by a file, model the ID as a subtype of BaseGroupedFileEntityId:
public sealed class RasterId : BaseGroupedFileEntityId
{
public RasterId(string relativeFilePath, string band = null)
: base(relativeFilePath, band) {}
}
// entity
public sealed class Raster : BaseEntity<RasterId>
{
public Raster(RasterId id, int width, int height) : base(id)
{
Width = width; Height = height;
}
public int Width { get; }
public int Height { get; }
}
You now get:
Id.Group(folder parts of the relative path)Id.FileNameandId.Name(e.g.,"map.tif;2"whenObjIdis used)- Equality by
FullNameso lookups are stable.
Pair this with a repository that uses an IStreamableFileSource:
public sealed class RasterRepository
: IUpdatableRepository<Raster, RasterId>,
IDiscreteRepository<Raster, RasterId>,
IStreamableRepository<RasterId>
{
// ... index your files; implement GetAll/Add/Remove/Update …
public (Maybe<Stream>, string fileType, string fileName) GetStream(RasterId id, ClaimsPrincipal user = null)
{
if (!_fileSource.Exists(id.RelativeFilePath)) return (Maybe.Empty<Stream>(), "", "");
return (_fileSource.OpenRead(id.RelativeFilePath).ToMaybe(), "tif", id.FileName);
}
}
Your service can then also implement IStreamableService<RasterId> and expose GetStream.
The Jobs module as a reference¶
The DHI.Services.Jobs package shows the idioms:
- Entity:
Job<TJobId,TTaskId> : BaseEntity<TJobId>— adds status, timestamps, parameters. - Repository:
JobRepository<TJobId,TTaskId> : JsonRepository<Job<…>>— JSON persistence + convenience queries (GetLast,UpdateField, expression queries). - Service:
JobService<TTask,TTaskId> : BaseUpdatableDiscreteService<Job<Guid,TTaskId>, Guid>— enforces invariants (valid account, valid task), status transitions (setsStarted/Finished, guards for cancel), batch delete with cancellable event.
You can mirror this approach for your own domains: keep entities small, let repositories persist, centralize business rules in services, and raise events for the host app to participate.
Provider discovery (plugins)¶
If your module supports pluggable repositories, use:
// in your service/repo or a setup utility:
var types = Service.GetProviderTypes<IMyRepository>(path: AppContext.BaseDirectory, searchPattern: "my*.dll");
// reflect over these and construct based on configuration
This scans assemblies and returns types that implement your abstraction.
Exposing your service over HTTP (optional)¶
If/when you create a Web API package (e.g., DHI.Services.Sensors.WebApi), you’ll follow the same template as the built-in modules:
-
In
Program.cs, add module JSON converters:options.JsonSerializerOptions.AddConverters( DHI.Services.Sensors.WebApi.SerializerOptionsDefault.Options.Converters); -
Register XML docs in Swagger so endpoint models/descriptions appear.
- If the module supports SignalR features, register
builder.Services.AddSignalR()and anIFilterRepository.
Developers then use routes like /api/sensors/{connectionId}/... where connectionId is the key from connections.json.
API reference (most-used members)¶
Entities¶
BaseEntity<TId>:Id,Metadata,Permissions,Added,Updated,Clone<T>(),IsAllowed(…)BaseNamedEntity<TId>:NameBaseGroupedEntity<TId>:Group,FullNameBaseGroupedFileEntityId:RelativeFilePath,FileName,Group,ObjId,Name,FullName
Repositories (interfaces)¶
IRepository<TEntity,TId>:Maybe<TEntity> Get(TId, ClaimsPrincipal?)IDiscreteRepository<TEntity,TId>:GetAll,Count,Contains,GetIdsIUpdatableRepository<TEntity,TId>:Add,Update,RemoveIImmutableRepository<TEntity,TId>:Add,RemoveIGroupedRepository<TEntity>:ContainsGroup,GetByGroup,GetFullNamesIGroupedUpdatableRepository:RemoveByGroupIStreamableRepository<TId>:GetStream(TId)
Services (interfaces)¶
IService<TEntity,TId>:TryGet,TryGet(IEnumerable ids, …)IDiscreteService<TEntity,TId>:GetAll,Count,Exists,GetIdsIUpdatableService<TEntity,TId>:Add/Update/Remove,AddOrUpdate,TryAdd/TryUpdate, eventsIImmutableService<TEntity,TId>:Add,TryAdd,Remove, eventsIGroupedService<TEntity>:GetByGroup,GetFullNames,GroupExistsIGroupedUpdatableService:RemoveByGroup,DeletingGroup/DeletedGroupIStreamableService<TId>:GetStream(TId)
Infrastructure¶
ServiceLocator:Register(object, id),Get<T>(id),Remove(id),Ids,CountServices.Configure(IConnectionRepository, bool lazyCreation)Services.Get<TService>(connectionId)Service.GetProviderTypes<TAbstraction>(path?, pattern?)
Worked example: building a group-aware domain¶
public sealed class Model : BaseGroupedEntity<Guid>
{
public Model(Guid id, string name, string project, string engine) : base(id, name, metadata: null, permissions: null)
{
Group = project; // inherited
Engine = engine;
}
public string Engine { get; }
}
public interface IModelRepository :
IUpdatableRepository<Model, Guid>,
IDiscreteRepository<Model, Guid>,
IGroupedRepository<Model>,
IGroupedUpdatableRepository {}
public sealed class ModelRepository : JsonRepository<Model, Guid>, IModelRepository
{
public ModelRepository(string filePath) : base(filePath) {}
public bool ContainsGroup(string group, ClaimsPrincipal user = null)
=> GetAll(user).Any(m => m.Group == group);
public IEnumerable<Model> GetByGroup(string group, ClaimsPrincipal user = null)
=> GetAll(user).Where(m => m.Group == group);
public IEnumerable<string> GetFullNames(string group, ClaimsPrincipal user = null)
=> GetByGroup(group, user).Select(m => m.FullName);
public IEnumerable<string> GetFullNames(ClaimsPrincipal user = null)
=> GetAll(user).Select(m => m.FullName);
public void RemoveByGroup(string group, ClaimsPrincipal user = null)
=> Remove(m => m.Group == group, user);
}
public interface IModelService :
IUpdatableService<Model, Guid>,
IDiscreteService<Model, Guid>,
IGroupedService<Model>,
IGroupedUpdatableService {}
public sealed class ModelService
: BaseGroupedUpdatableDiscreteService<Model, Guid>, IModelService
{
public ModelService(IModelRepository repo, ILogger<ModelService> logger = null) : base(repo, logger) {}
}
Usage:
var repo = new ModelRepository("[AppData]models.json".Resolve());
var svc = new ModelService(repo);
ServiceLocator.Register(svc, "models-json");
svc.Add(new Model(Guid.NewGuid(), "Harbour", "ProjectA", "MIKE 21"));
var list = svc.GetByGroup("ProjectA");
svc.RemoveByGroup("ProjectA");
Guard clauses (null/empty/format/range checks)¶
DHI.Services ships a tiny, fluent guard library to keep constructors and public APIs defensive and self-documenting.
What it is¶
- Entry point:
Guard.Against(implementsIGuardClause) - Extensions:
GuardClauseExtensionsonIGuardClause - Analyzer hint:
ValidatedNotNullAttributeon parameters to help static analysis tools
Common guards (each throws on failure):
| Guard | Throws | Typical use |
|---|---|---|
Null(obj, nameof(obj)) |
ArgumentNullException |
Required reference parameter (IDs, repository deps, file sources) |
NullOrEmpty(string, nameof(s)) |
ArgumentException |
Required strings (names, IDs) |
NullOrWhiteSpace(string, nameof(s)) |
ArgumentException |
Required strings that must include non-whitespace |
NullOrAnySpace(string, nameof(s)) |
ArgumentException |
Tokens/keys that must not contain spaces (e.g., connectionId) |
NullOrEmpty<T>(IEnumerable<T>, nameof(xs)) |
ArgumentException |
Required collections (groups, IDs) |
NegativeOrZero(int\float\double, nameof(n)) |
ArgumentException |
Positive numbers only (page size, counts, buffer lengths) |
Return-overloads:
Null<T>(…)andNegativeOrZero(…)also return the input when valid—handy for single-line assignments.
Usage patterns¶
In entity constructors
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));
}
}
In service methods
public void Rename(Guid id, string newName)
{
Guard.Against.NullOrWhiteSpace(newName, nameof(newName));
if (!TryGet(id, out var entity)) throw new KeyNotFoundException(...);
entity.Name = newName; Update(entity);
}
Validating lists/collections
public IEnumerable<Model> GetByGroups(IEnumerable<string> groups)
{
Guard.Against.NullOrEmpty(groups, nameof(groups));
return groups.SelectMany(g => GetByGroup(g));
}
Enforcing numeric ranges
public Page<T> GetPage(int page, int pageSize)
{
Guard.Against.NegativeOrZero(page, nameof(page));
Guard.Against.NegativeOrZero(pageSize, nameof(pageSize));
...
}
Tokens without spaces
public void RegisterConnection(string connectionId, string repositoryType)
{
Guard.Against.NullOrAnySpace(connectionId, nameof(connectionId));
Guard.Against.NullOrWhiteSpace(repositoryType, nameof(repositoryType));
...
}
Best practices¶
- Guard at the boundary (ctors, public methods). Downstream code can then assume invariants.
- Prefer
Guard.Against.…to ad-hocif (…) throw …for consistency and tidy stack traces. - Always pass
nameof(param)—messages get clearer, and refactors stay safe. - Use
Null<T>andNegativeOrZeroreturn values to keep assignment concise. - Throw early, throw specific: choose
NullvsNullOrWhiteSpacebased on the real invariant.
Next: Web API Core
Appendix: tiny code snippets you’ll probably copy¶
Grant a role permissions for multiple operations
entity.AddPermissions(new[] { "Editors" }, new[] { "read", "update", "delete" });
Use Maybe<T> inline
var maybe = repo.Get(id);
var value = maybe | new Sensor(Guid.NewGuid(), "fallback", "unit");
// or
var value2 = maybe | (() => expensiveDefault());
Service logging (already integrated)
public sealed class MyService : BaseUpdatableDiscreteService<MyEntity, Guid>
{
public MyService(IUpdatableRepository<MyEntity, Guid> repo, ILogger<MyService> logger)
: base(repo, logger) { }
// calls to TryGet/Add/Update will use the logger for errors
}