Skip to content

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, Metadata
  • ITraceableEntity<TId> -> Added, Updated
  • ISecuredEntity<TId> -> Permissions and IsAllowed(…)
  • INamedEntity<TId> -> Name
  • IGroupedEntity<TId> -> Group, FullName

Security note: IsAllowed honours deny over allow. If any matching permission is Denied, 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 / Updated on entities implementing ITraceableEntity<TId>.


Utilities you’ll use

  • Maybe<T> — small optional wrapper returned by repositories. Prefer TryGet(…) APIs in services over throwing.
  • Permission — value type with Principals, 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 BaseGroupedFileEntityId and use that as TId

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 add group to ctor; you’ll get Group and FullName.

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 ISensorRepository against your provider API instead of inheriting JsonRepository.

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.FileName and Id.Name (e.g., "map.tif;2" when ObjId is used)
  • Equality by FullName so 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 (sets Started/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 an IFilterRepository.

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>: Name
  • BaseGroupedEntity<TId>: Group, FullName
  • BaseGroupedFileEntityId: RelativeFilePath, FileName, Group, ObjId, Name, FullName

Repositories (interfaces)

  • IRepository<TEntity,TId>: Maybe<TEntity> Get(TId, ClaimsPrincipal?)
  • IDiscreteRepository<TEntity,TId>: GetAll, Count, Contains, GetIds
  • IUpdatableRepository<TEntity,TId>: Add, Update, Remove
  • IImmutableRepository<TEntity,TId>: Add, Remove
  • IGroupedRepository<TEntity>: ContainsGroup, GetByGroup, GetFullNames
  • IGroupedUpdatableRepository: RemoveByGroup
  • IStreamableRepository<TId>: GetStream(TId)

Services (interfaces)

  • IService<TEntity,TId>: TryGet, TryGet(IEnumerable ids, …)
  • IDiscreteService<TEntity,TId>: GetAll, Count, Exists, GetIds
  • IUpdatableService<TEntity,TId>: Add/Update/Remove, AddOrUpdate, TryAdd/TryUpdate, events
  • IImmutableService<TEntity,TId>: Add, TryAdd, Remove, events
  • IGroupedService<TEntity>: GetByGroup, GetFullNames, GroupExists
  • IGroupedUpdatableService: RemoveByGroup, DeletingGroup/DeletedGroup
  • IStreamableService<TId>: GetStream(TId)

Infrastructure

  • ServiceLocator: Register(object, id), Get<T>(id), Remove(id), Ids, Count
  • Services.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 (implements IGuardClause)
  • Extensions: GuardClauseExtensions on IGuardClause
  • Analyzer hint: ValidatedNotNullAttribute on 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>(…) and NegativeOrZero(…) 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-hoc if (…) throw … for consistency and tidy stack traces.
  • Always pass nameof(param)—messages get clearer, and refactors stay safe.
  • Use Null<T> and NegativeOrZero return values to keep assignment concise.
  • Throw early, throw specific: choose Null vs NullOrWhiteSpace based 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
}