Skip to content

DHI.Services.Scalars — Internal Guide

Scalars are named key–value pairs (optionally grouped) with a timestamp and an optional quality flag. Think “configuration values,” “latest KPI,” “thresholds,” “feature toggles,” “site parameters,” etc.

This module gives you:

  • Entity: Scalar<TId,TFlag> (inherits BaseGroupedEntity<TId>)
  • Data wrapper: ScalarData<TFlag>{ Value, DateTime, Flag? }
  • Repositories: abstractions + a default JSON file repo
  • Services: CRUD + convenience operations (set data, upsert, lock)
  • Connections: factory for wiring services from connections.json

You can start with the JSON repository for demos/dev, and later swap to a provider-backed repository (e.g., PostgreSQL) by implementing IScalarRepository<,> or IGroupedScalarRepository<,>.


1) Core types at a glance

Scalar

public class Scalar<TId, TFlag> : BaseGroupedEntity<TId> where TFlag : struct
{
    public string ValueTypeName { get; }     // e.g. "System.Double" or "System.Guid"
    public string Description { get; set; }
    public bool Locked { get; set; }         // protects against updates
    public void SetData(ScalarData<TFlag> data);         // type-safe setter
    public Maybe<ScalarData<TFlag>> GetData();           // returns Maybe<T>
}
  • Id & Name & Group come from BaseGroupedEntity<TId> → you also get FullName = "{Group}/{Name}".
  • ValueTypeName is the .NET type name of the Value you plan to store (e.g., "System.Double"). SetData will validate that data.Value.GetType() matches this.
  • Locked prevents updates (including SetData, Update, AddOrUpdate).

Convenience specializations:

  • Scalar<TFlag>TId = string
  • ScalarTId = string, TFlag = int

ScalarData

public sealed class ScalarData<TFlag> where TFlag : struct
{
    public object Value { get; }     // actual value (double, string, Guid, etc.)
    public DateTime DateTime { get; }
    public TFlag? Flag { get; }      // optional quality flag (int or enum)
    // Value/Flag equality; DateTime is not part of equality
}

Equality semantics: ScalarData<TFlag>.Equals compares Value and Flag only (not DateTime).

Services

public interface IScalarService<TId, TFlag> : 
  IService<Scalar<TId,TFlag>,TId>,
  IDiscreteService<...>,
  IUpdatableService<...>
{
    void SetData(TId id, ScalarData<TFlag> data, bool log = true, ClaimsPrincipal user = null);
    bool TrySetDataOrAdd(Scalar<TId, TFlag> scalar, bool log = true, ClaimsPrincipal user = null);
    void SetLocked(TId id, bool locked, ClaimsPrincipal user = null);
}
  • ScalarService<TId,TFlag> inherits BaseUpdatableDiscreteService<...> (CRUD + list).
  • Updated event: event EventHandler<EventArgs<(Scalar before, Scalar after, bool log)>> Updated; If you pass a logger to the constructor, the service logs value changes when log == true.

Grouped variant:

public interface IGroupedScalarService<TId,TFlag> : 
  IScalarService<TId,TFlag>,
  IGroupedService<Scalar<TId,TFlag>>  // GetByGroup, GetFullNames, RemoveByGroup (via base)

Repositories

Abstractions you can implement for any backend:

public interface IScalarRepository<TId,TFlag> : 
  IRepository<Scalar<TId,TFlag>,TId>,
  IDiscreteRepository<...>,
  IUpdatableRepository<...>
{
    void SetData(TId id, ScalarData<TFlag> data, ClaimsPrincipal user = null);
    void SetLocked(TId id, bool locked, ClaimsPrincipal user = null);
}

public interface IGroupedScalarRepository<TId,TFlag> :
  IScalarRepository<TId,TFlag>,
  IGroupedRepository<Scalar<TId,TFlag>>
{ }

Base classes you can derive from:

  • BaseScalarRepository<TId,TFlag> (adds default SetData/SetLocked)
  • BaseGroupedScalarRepository<TId,TFlag> (adds grouped helpers)

Default JSON repository (ready to use):

public sealed class ScalarRepository :
  GroupedJsonRepository<Scalar<string,int>>,
  IGroupedScalarRepository<string,int>
{
    // ctor(filePath, converters...) + SetData + SetLocked
}

It depends on a custom ScalarConverter<TId,TFlag> to serialize _data consistently.


2) Typical uses

  • Configuration & feature flags: "featureX/enabled" = true
  • Thresholds/limits: "plantA/maxFlow" = 12.5 (Validated)"
  • Latest metrics: "kpi/revenue-mtd" = 123456.78"
  • IDs & tokens: "integrations/some-api/key" = "abcdef..." (keep secure!)"
  • Reference values per site/project (use groups for scoping)

3) Quickstart (JSON-backed)

Install:

dotnet add package DHI.Services.Scalars

Create a JSON file (empty object is fine at start):

{}

Wire up the service imperatively:

using DHI.Services;
using DHI.Services.Scalars;

// repo + service
var repo = new ScalarRepository("[AppData]scalars.json".Resolve());
var svc  = new GroupedScalarService(repo); // string id, int flag

// register (optional but handy)
ServiceLocator.Register(svc, "scalars-json");

// add a scalar
var id   = "salinity.threshold";
var name = "Salinity Threshold";
var type = typeof(double).AssemblyQualifiedName ?? "System.Double"; // ValueTypeName
var scalar = new Scalar(id, name, type, group: "ProjectA");
svc.Add(scalar);

// set data (value + timestamp + optional flag)
svc.SetData(id, new ScalarData(35.0, DateTime.UtcNow, flag: 1));  // int flag

Read it back:

if (svc.TryGet(id, out var s) && s.GetData().HasValue)
{
    var data = s.GetData().Value;
    var value = (double)data.Value;    // cast to your declared ValueTypeName
    Console.WriteLine($"{s.FullName} = {value} at {data.DateTime} (flag {data.Flag})");
}

Lock it (prevent edits):

svc.SetLocked(id, true);

Upsert convenience:

var up = new Scalar(id, name, type, "ProjectA",
    new ScalarData(36.0, DateTime.UtcNow, 1));
var ok = svc.TrySetDataOrAdd(up); // add if missing, otherwise overwrite data (respects Locked)

Grouped queries:

var projectScalars   = svc.GetByGroup("ProjectA");
var projectFullNames = svc.GetFullNames("ProjectA");
svc.RemoveByGroup("ProjectA"); // from BaseGroupedUpdatableDiscreteService

All add/update/remove operations raise the usual lifecycle events (Adding/Added, Updating/Updated, Deleting/Deleted). SetData flows through Updating/Updated too.


4) Value typing & the JSON converter

  • You must set ValueTypeName to the .NET type of your value exactly (e.g., "System.Double", "System.Int32", "System.Guid"). SetData throws if the runtime type of data.Value doesn’t match.
  • For some complex types (e.g., Guid) the ScalarConverter will serialize _data.Value as a string to ensure round-tripping.

Serialized shape (simplified):

{
  "salinity.threshold": {
    "Id": "salinity.threshold",
    "Name": "Salinity Threshold",
    "FullName": "ProjectA/Salinity Threshold",
    "Group": "ProjectA",
    "ValueTypeName": "System.Double",
    "Description": "Max allowed salinity",
    "Locked": false,
    "_data": { "Value": 35.0, "DateTime": "2025-01-10T08:00:00Z", "Flag": 1 }
  }
}

5) Services in detail

ScalarService<TId,TFlag>

  • Implements full CRUD and listing (IDiscreteService).
  • Guards:
    • Update / AddOrUpdate / SetData will throw if the scalar exists and Locked == true.
    • SetLocked throws if the id does not exist.
  • Logging:
    • If you pass an ILogger to the constructor, the service subscribes to its own Updated event and logs when the value actually changes (determined by ScalarData.Equals).
    • SetData(..., log: false) suppresses this log for that call.

GroupedScalarService<TId,TFlag>

  • Same as ScalarService plus group-aware operations via IGroupedService:
    • GroupExists, GetByGroup, GetFullNames, and RemoveByGroup (the latter raises DeletingGroup/DeletedGroup events).
  • Use this variant whenever you model scalars per project/site/tenant.

Event model (what you can hook)

From base updatable services:

  • Adding / Added
  • Updating / Updated (ScalarService replaces/extends with a tuple: before, after, log)
  • Deleting / Deleted

From grouped services:

  • DeletingGroup / DeletedGroup

Example: audit log all scalar changes:

svc.Updated += (_, e) =>
{
    var (before, after, log) = e.Item;
    if (!log) return;
    var oldVal = before.GetData().HasValue ? before.GetData().Value.ToString() : "<none>";
    var newVal = after.GetData().HasValue ? after.GetData().Value.ToString() : "<none>";
    Console.WriteLine($"[{DateTime.UtcNow}] {before.FullName}: {oldVal} → {newVal}");
};

6) Choosing ID & Flag types

You can use the generic types directly:

  • TId: string (most common), Guid, or a domain-specific identifier
  • TFlag: int or an enum (recommended for readability)

Example using an enum flag:

public enum Quality { Raw = 0, Validated = 1, Estimated = 2 }

var type = typeof(double).FullName;  // "System.Double"
var s = new Scalar<string, Quality>("coefs.alpha", "Alpha", type, "ModelA");
svc.Add(s);
svc.SetData("coefs.alpha", new ScalarData<Quality>(0.123, DateTime.UtcNow, Quality.Validated));

If you don’t need generics, use the convenience types:

  • ScalarScalar<string, int>
  • Scalar<TFlag>Scalar<string, TFlag>

7) Wiring via connections.json (no code registration)

If you use the Connections pattern, the module ships connection classes that can instantiate services from JSON config:

  • ScalarServiceConnection<TId,TFlag>
  • GroupedScalarServiceConnection<TId,TFlag>

Example (App_Data/connections.json):

{
  "$type": "System.Collections.Generic.Dictionary`2[[System.String, mscorlib],[DHI.Services.IConnection, DHI.Services]], mscorlib",
  "json-scalars": {
    "$type": "DHI.Services.Scalars.ScalarServiceConnection`2[[System.String, mscorlib],[System.Int32, mscorlib]], DHI.Services.Scalars",
    "RepositoryType": "DHI.Services.Scalars.ScalarRepository, DHI.Services.Scalars",
    "RepositoryConnectionString": "[AppData]scalars.json",
    "Name": "Scalars (JSON)",
    "Id": "json-scalars"
  },
  "json-scalars-grouped": {
    "$type": "DHI.Services.Scalars.GroupedScalarServiceConnection`2[[System.String, mscorlib],[System.Int32, mscorlib]], DHI.Services.Scalars",
    "RepositoryType": "DHI.Services.Scalars.ScalarRepository, DHI.Services.Scalars",
    "RepositoryConnectionString": "[AppData]scalars.json",
    "Name": "Scalars (JSON, grouped)",
    "Id": "json-scalars-grouped"
  }
}

Startup:

// one-time
Services.Configure(new ConnectionRepository("[AppData]connections.json".Resolve()), lazyCreation: true);

// later
var svc = Services.Get<IScalarService<string,int>>("json-scalars");

You can also pass a logger via LoggerType/LoggerConnectionString if you have a logger provider you want to construct that way.


8) Exposing over HTTP (optional)

If you’re building a Web API:

  1. Install the Web API package for Scalars (if your solution includes it).
  2. In Program.cs, add converters for the module’s Web API assembly:
options.JsonSerializerOptions
    .AddConverters(DHI.Services.Scalars.WebApi.SerializerOptionsDefault.Options.Converters);
  1. Register Swagger XML for the module’s assemblies (so models are documented).
  2. Use routes like /api/scalars/{connectionId}/... where {connectionId} is from connections.json.

9) Implementing your own provider

To back Scalars with a database or external API, implement:

public sealed class PgScalarRepository :
  IGroupedScalarRepository<string,int>
{
    // IDiscreteRepository
    public int Count(ClaimsPrincipal user = null) { ... }
    public bool Contains(string id, ClaimsPrincipal user = null) { ... }
    public IEnumerable<Scalar<string,int>> GetAll(ClaimsPrincipal user = null) { ... }
    public IEnumerable<string> GetIds(ClaimsPrincipal user = null) { ... }
    public Maybe<Scalar<string,int>> Get(string id, ClaimsPrincipal user = null) { ... }

    // IUpdatableRepository
    public void Add(Scalar<string,int> entity, ClaimsPrincipal user = null) { ... }
    public void Update(Scalar<string,int> entity, ClaimsPrincipal user = null) { ... }
    public void Remove(string id, ClaimsPrincipal user = null) { ... }

    // IScalarRepository extras
    public void SetData(string id, ScalarData<int> data, ClaimsPrincipal user = null) { ... }
    public void SetLocked(string id, bool locked, ClaimsPrincipal user = null) { ... }

    // Grouped
    public bool ContainsGroup(string group, ClaimsPrincipal user = null) { ... }
    public IEnumerable<Scalar<string,int>> GetByGroup(string group, ClaimsPrincipal user = null) { ... }
    public IEnumerable<string> GetFullNames(string group, ClaimsPrincipal user = null)
        => GetByGroup(group, user).Select(s => s.FullName);
    public IEnumerable<string> GetFullNames(ClaimsPrincipal user = null)
        => GetAll(user).Select(s => s.FullName);
}

Then either register it in code:

var svc = new GroupedScalarService<string,int>(new PgScalarRepository(...));

or declare it in connections.json with

"RepositoryType": "Your.Assembly.Namespace.PgScalarRepository, Your.Assembly"

Provider discovery helpers:

ScalarService<string,int>.GetRepositoryTypes(path?, searchPattern?);
GroupedScalarService<string,int>.GetRepositoryTypes(...);

10) Security & permissions

Because Scalar<TId,TFlag> inherits BaseGroupedEntity<TId>, each scalar has:

  • Permissions: IList<Permission> and IsAllowed(...) helpers
  • Metadata: IDictionary<string,object>
  • Audit: Added / Updated

You can attach permissions per scalar:

scalar.AddPermissions(new[] { "Editors" }, new[] { "read", "update" });
scalar.AddPermission(new[] { "Guests" }, "read", PermissionType.Denied);

Deny wins over allow (as in the Core).


11) Behavior

  • Locked scalars: Update, AddOrUpdate, SetData, and the upsert path will throw (or return false in Try* variants) if Locked == true.
  • Not found:
    • SetData and SetLocked throw KeyNotFoundException if the id is missing.
    • Prefer TryGet from the service if you want a non-throwing read.
  • Type mismatches: SetData throws if data.Value.GetType() differs from Type.GetType(ValueTypeName).
  • Equality: ScalarData.Equals ignores DateTime—only Value+Flag matter. This is relevant for “did the value change?” logging.
  • Casting: When reading, cast GetData().Value.Value to the declared ValueTypeName (e.g., (double)scalar.GetData().Value.Value).
  • JSON repo: is thread-safe via internal locks; it preserves original Added on updates.

12) Minimal patterns you’ll re-use

Create + set data (double)

var s = new Scalar("pump.flow.min", "Min Pump Flow", typeof(double).FullName, "PlantA");
svc.Add(s);
svc.SetData(s.Id, new ScalarData(1.75, DateTime.UtcNow));

Upsert without exceptions

var s = new Scalar("plant.mode", "Plant Mode", typeof(string).FullName, "PlantA",
                   new ScalarData("Auto", DateTime.UtcNow, 0));
var ok = svc.TrySetDataOrAdd(s); // true if set or added

Lock & guard

svc.SetLocked("plant.mode", true);
var changed = svc.TrySetDataOrAdd(new Scalar("plant.mode", "Plant Mode", typeof(string).FullName,
                     new ScalarData("Manual", DateTime.UtcNow))); // returns false

Groups

var plantScalars = svc.GetByGroup("PlantA");
svc.RemoveByGroup("PlantA"); // raises DeletingGroup/DeletedGroup

13) When to use which class

You need… Use this
Simple key–value store (string IDs, int flags) Scalar, ScalarData, ScalarService
Enum quality flags Scalar<TFlag>, ScalarData<TFlag>, ScalarService<TFlag>
Custom ID type (e.g., Guid) Scalar<Guid, TFlag> + ScalarService<Guid, TFlag>
Per-project/site partitioning GroupedScalarService<...> + a IGroupedScalarRepository<...>
File-based dev/demo storage ScalarRepository (JSON)
Production DB/API Implement I(Grouped)ScalarRepository<,>

14) Package names you’ll install

  • Core: DHI.Services.Scalars (entities, services, JSON repo, connections)
  • (Optional) Web API: DHI.Services.Scalars.WebApi (controllers, converters for ASP.NET Core)
  • Core runtime: DHI.Services (pulled transitively in most cases)

That’s the Scalar module in one place. If you want, I can also add a small “playbook” showing how to migrate from the JSON repository to a PostgreSQL provider, or a ready-to-copy connections.json bundle for multiple environments.