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>(inheritsBaseGroupedEntity<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 getFullName = "{Group}/{Name}". - ValueTypeName is the .NET type name of the Value you plan to store (e.g.,
"System.Double").SetDatawill validate thatdata.Value.GetType()matches this. - Locked prevents updates (including
SetData,Update,AddOrUpdate).
Convenience specializations:
Scalar<TFlag>→TId = stringScalar→TId = 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>.Equalscompares Value and Flag only (notDateTime).
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>inheritsBaseUpdatableDiscreteService<...>(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 whenlog == 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 defaultSetData/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).SetDataflows throughUpdating/Updatedtoo.
4) Value typing & the JSON converter¶
- You must set
ValueTypeNameto the .NET type of your value exactly (e.g.,"System.Double","System.Int32","System.Guid").SetDatathrows if the runtime type ofdata.Valuedoesn’t match. - For some complex types (e.g.,
Guid) theScalarConverterwill serialize_data.Valueas 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/SetDatawill throw if the scalar exists andLocked == true.SetLockedthrows if the id does not exist.
- Logging:
- If you pass an
ILoggerto the constructor, the service subscribes to its ownUpdatedevent and logs when the value actually changes (determined byScalarData.Equals). SetData(..., log: false)suppresses this log for that call.
- If you pass an
GroupedScalarService<TId,TFlag>¶
- Same as
ScalarServiceplus group-aware operations viaIGroupedService:GroupExists,GetByGroup,GetFullNames, andRemoveByGroup(the latter raisesDeletingGroup/DeletedGroupevents).
- Use this variant whenever you model scalars per project/site/tenant.
Event model (what you can hook)¶
From base updatable services:
Adding/AddedUpdating/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 identifierTFlag:intor 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:
Scalar→Scalar<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/LoggerConnectionStringif you have a logger provider you want to construct that way.
8) Exposing over HTTP (optional)¶
If you’re building a Web API:
- Install the Web API package for Scalars (if your solution includes it).
- In
Program.cs, add converters for the module’s Web API assembly:
options.JsonSerializerOptions
.AddConverters(DHI.Services.Scalars.WebApi.SerializerOptionsDefault.Options.Converters);
- Register Swagger XML for the module’s assemblies (so models are documented).
- Use routes like
/api/scalars/{connectionId}/...where{connectionId}is fromconnections.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>andIsAllowed(...)helpersMetadata: 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 returnfalseinTry*variants) ifLocked == true. - Not found:
SetDataandSetLockedthrowKeyNotFoundExceptionif the id is missing.- Prefer
TryGetfrom the service if you want a non-throwing read.
- Type mismatches:
SetDatathrows ifdata.Value.GetType()differs fromType.GetType(ValueTypeName). - Equality:
ScalarData.EqualsignoresDateTime—only Value+Flag matter. This is relevant for “did the value change?” logging. - Casting: When reading, cast
GetData().Value.Valueto the declaredValueTypeName(e.g.,(double)scalar.GetData().Value.Value). - JSON repo: is thread-safe via internal locks; it preserves original
Addedon 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.