DHI.Services.JsonDocuments — Internal Developer Guide (Core)¶
This module is a lightweight way to store, group, query, and selectively project JSON payloads. It provides:
- A domain model (
JsonDocument<TId>) with metadata, grouping, permissions, and optional timestamping. - A repository contract (
IJsonDocumentRepository<TId>) for storage engines. - A service (
JsonDocumentService<TId>) with guards, events, and optional notification logging. - A tiny JSON-projection helper (data selectors) built on
Argon’s JSONPath.
It’s deliberately small and unopinionated about persistence—repositories can back onto SQL, NoSQL, files, in-memory, etc.
Core types¶
JsonDocument<TId>¶
A groupable, permission-aware JSON blob.
var doc = new JsonDocument<string>(
id: "cfg:sim-42",
name: "Simulation 42",
group: "configs/simulations",
data: "{\"params\":{\"dt\":60,\"spinup\":3600},\"notes\":\"baseline\"}",
metadata: new Dictionary<string, object> { ["owner"] = "ops" },
permissions: new List<Permission> { /* DHI.Services.Authorization.Permission */ }
)
{
DateTime = DateTime.UtcNow // optional
};
Key members:
string Data { get; private set; }— the raw JSON string (validated at construction).string Name,string Group,IDictionary<string,object> Metadata,IList<Permission> Permissions— inherited fromBaseGroupedEntity<TId>.DateTime? DateTime— optional timestamp for time-range queries.DateTime? Deleted— soft-delete marker (set by the service’sTrySoftRemove).Filter(string[] selectors)— destructively replacesDatawith a projection (see “Data selectors”).
JSON validation uses
System.Text.Jsonunder the hood; non-JSON strings will throw in the constructor.
IJsonDocumentRepository<TId>¶
Storage abstraction. You implement this to talk to your backing store.
public interface IJsonDocumentRepository<TId> :
IRepository<JsonDocument<TId>, TId>,
IDiscreteRepository<JsonDocument<TId>, TId>,
IGroupedRepository<JsonDocument<TId>>,
IUpdatableRepository<JsonDocument<TId>, TId>
{
Maybe<JsonDocument<TId>> Get(TId id, string[] dataSelectors = null, ClaimsPrincipal user = null);
IEnumerable<JsonDocument<TId>> GetByGroup(string group, string[] dataSelectors = null, ClaimsPrincipal user = null);
IEnumerable<JsonDocument<TId>> GetAll(string[] dataSelectors = null, ClaimsPrincipal user = null);
IEnumerable<JsonDocument<TId>> Get(DateTime from, DateTime to, string[] dataSelectors = null, ClaimsPrincipal user = null);
IEnumerable<JsonDocument<TId>> Get(Query<JsonDocument<TId>> query, string[] dataSelectors = null, ClaimsPrincipal user = null);
}
Expectations for implementers
- Respect grouping semantics from
IGroupedRepository(ContainsGroup,GetByGroup, etc.). - Enforce or ignore
ClaimsPrincipalas your domain requires. - Apply data selectors before returning documents (recommended; see below).
- Decide soft-delete behavior: either exclude
Deleted != nullby default, or expose that as a query option.
JsonDocumentService<TId>¶
A thin business layer over the repository with consistent precondition checks and events. It also optionally logs notifications when constructed with an INotificationRepository.
Frequently used members:
var svc = new JsonDocumentService<string>(repo, logRepo);
// Reads (selectors optional)
var one = svc.Get("cfg:sim-42", dataSelectors: new[] { "$.params.dt" });
var all = svc.GetAll(dataSelectors: Array.Empty<string>()); // no projection
var inGrp = svc.GetByGroup("configs/simulations", new[] { "$.params" });
var window = svc.Get(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow, new[] { "$.notes" });
// Writes (guards + events + optional logging)
svc.Add(doc);
svc.Update(doc);
svc.AddOrUpdate(doc);
svc.Remove("cfg:sim-42");
// Soft delete (set Deleted timestamp and Update)
svc.TrySoftRemove("cfg:sim-42");
Events (raised after repo calls):
Added += (_, (doc, userName)) => …Updated += (_, (doc, userName)) => …Deleted += (_, (doc, userName)) => …
If constructed with INotificationRepository, Added/Updated automatically create NotificationEntry records with metadata { jsonDocumentId, userName? }.
Guard behavior
Addthrows if id exists;TryAddswallows and returns false.Updatethrows if id missing;TryUpdateswallows and returns false.AddOrUpdatedoes the right thing on existence.GetByGroupthrows if the group doesn’t exist (based on repository’sContainsGroup).
Data selectors (JSON projection)¶
The selector mechanism lets callers fetch only parts of large JSONs. It’s implemented via an Argon (Json.NET) helper:
public static Argon.JObject Filter(this Argon.JObject obj, params string[] selects)
And on the entity:
doc.Filter(new[] { "$.params.dt", "$.notes" }); // replaces Data with a reduced JSON
How it works¶
- Selectors are passed to
Argon.JObject.SelectToken(selector)(JSONPath / JPath). - For each matching token, the helper rebuilds the ancestor chain so the result is still a valid object graph containing only the requested branches.
- Multiple selectors are merged.
What to expect (and what not)¶
- Properties and array indices work:
$.params.dt$.items[0].name
- Quoted accessors if needed:
$['strange-name']
- Wildcards / filters (e.g.
$.items[*].id,$..foo,?(@.id>1)) are not supported by the current implementation because it usesSelectToken(single token) rather thanSelectTokens(multiple). You’ll typically get the first match ornull. - Selections that land inside arrays are carried along, but parent array handling is minimal—prefer index-based selectors (e.g.,
items[3]) rather than wildcards.
Examples¶
Given:
{
"params": { "dt": 60, "spinup": 3600 },
"notes": "baseline",
"items": [{ "id": 1 }, { "id": 2 }]
}
Selectors -> projected result:
["$.params.dt"]⇒{ "params": { "dt": 60 } }["$.notes", "$.params.spinup"]⇒{ "params": { "spinup": 3600 }, "notes": "baseline" }["$.items[1]"]⇒{ "items": [ { "id": 2 } ] }(index included; sibling elements omitted)
Best practice: Always pass absolute JSONPaths starting at
$, and avoid wildcards. If you need multi-token selection, extend your repository to useSelectTokensand merge.
Typical usage patterns¶
Add or Update a document¶
var repo = /* your IJsonDocumentRepository<string> */;
var svc = new JsonDocumentService<string>(repo);
var doc = new JsonDocument("cfg:sim-42", "Simulation 42",
"{\"params\":{\"dt\":60,\"spinup\":3600},\"notes\":\"baseline\"}")
{
Group = "configs/simulations",
DateTime = DateTime.UtcNow,
Metadata = new Dictionary<string, object> { ["owner"] = "ops" }
};
svc.AddOrUpdate(doc);
Fetch with projection (only what the UI needs)¶
var partial = svc.Get("cfg:sim-42", new[] { "$.params", "$.notes" });
// partial.Data == {"params":{"dt":60,"spinup":3600},"notes":"baseline"}
Query by time window¶
var recent = svc.Get(
from: DateTime.UtcNow.AddHours(-6),
to: DateTime.UtcNow,
dataSelectors: new[] { "$.params.dt" }
);
Group navigation¶
foreach (var d in svc.GetByGroup("configs/simulations", new[] { "$.notes" }))
{
Console.WriteLine($"{d.Id}: {d.Data}");
}
Soft delete¶
var ok = svc.TrySoftRemove("cfg:sim-42");
Implementing a repository (minimal in-memory example)¶
This snippet shows how to honor selectors and grouping without any storage engine:
public sealed class InMemoryJsonDocRepository : IJsonDocumentRepository<string>
{
private readonly Dictionary<string, JsonDocument<string>> _store = new();
public bool Contains(string id, ClaimsPrincipal user = null) => _store.ContainsKey(id);
public bool ContainsGroup(string group, ClaimsPrincipal user = null) =>
_store.Values.Any(d => (d.Group ?? "") == (group ?? ""));
public Maybe<JsonDocument<string>> Get(string id, ClaimsPrincipal user = null) =>
_store.TryGetValue(id, out var d) ? d.ToMaybe() : Maybe.Empty<JsonDocument<string>>();
public Maybe<JsonDocument<string>> Get(string id, string[] selects, ClaimsPrincipal user = null)
{
if (!_store.TryGetValue(id, out var d)) return Maybe.Empty<JsonDocument<string>>();
return ApplySelectors(Clone(d), selects).ToMaybe();
}
public IEnumerable<JsonDocument<string>> GetAll(string[] selects = null, ClaimsPrincipal user = null) =>
_store.Values.Select(d => ApplySelectors(Clone(d), selects));
public IEnumerable<JsonDocument<string>> GetByGroup(string group, string[] selects = null, ClaimsPrincipal user = null) =>
_store.Values.Where(d => (d.Group ?? "") == (group ?? ""))
.Select(d => ApplySelectors(Clone(d), selects));
public IEnumerable<JsonDocument<string>> Get(DateTime from, DateTime to, string[] selects = null, ClaimsPrincipal user = null) =>
_store.Values.Where(d => d.DateTime >= from && d.DateTime <= to)
.Select(d => ApplySelectors(Clone(d), selects));
public IEnumerable<JsonDocument<string>> Get(Query<JsonDocument<string>> query, string[] selects = null, ClaimsPrincipal user = null)
{
// Minimal: support name equals. Extend per your needs.
var nameEq = query.Conditions.FirstOrDefault(c => c.Item == "Name" && c.QueryOperator == QueryOperator.Equal);
var items = nameEq is null ? _store.Values : _store.Values.Where(d => d.Name?.Equals(nameEq.Value?.ToString(), StringComparison.OrdinalIgnoreCase) == true);
return items.Select(d => ApplySelectors(Clone(d), selects));
}
public void Add(JsonDocument<string> e, ClaimsPrincipal user = null) => _store.Add(e.Id, e);
public void Update(JsonDocument<string> e, ClaimsPrincipal user = null) => _store[e.Id] = e;
public void Remove(string id, ClaimsPrincipal user = null) => _store.Remove(id);
// not used here
public IEnumerable<JsonDocument<string>> GetAll(ClaimsPrincipal user = null) => GetAll(Array.Empty<string>(), user);
public IEnumerable<string> GetFullNames(ClaimsPrincipal user = null) => Enumerable.Empty<string>();
private static JsonDocument<string> ApplySelectors(JsonDocument<string> doc, string[] selects) =>
(selects is { Length: >0 }) ? doc.Filter(selects) : doc;
private static JsonDocument<string> Clone(JsonDocument<string> d) =>
new(d.Id, d.Name, d.Group, d.Data, new Dictionary<string, object>(d.Metadata ?? new()), d.Permissions?.ToList())
{ DateTime = d.DateTime, Deleted = d.Deleted };
}
Production repositories (SQL/NoSQL) should consider indexing by
Id,Group, andDateTime, storeDataas text/JSON, and apply selectors after retrieval (or server-side projection if your DB supports JSON path projections).
Notifications (optional)¶
If you pass an INotificationRepository into the service’s constructor:
AddedandUpdatedautomatically emitNotificationEntrywith:Text: “JSON document '{id}' was {action} [by '{user}'].”Source:"JsonDocument Service"Metadata:{ jsonDocumentId = id, userName? }
- You can also:
GetNotificationEntries(id)— fetch logs for a document.AddNotificationEntry(id, entry)— add your own entries; the service will augment metadata withjsonDocumentId.
If no logger is configured, these methods throw a NotSupportedException with a clear message.
Error model & guardrails¶
- Creating a
JsonDocument<TId>with invalid JSON ⇒ArgumentException("The given data is not valid JSON format.", …). JsonDocumentService.Addwhen id exists ⇒ArgumentException("…already exists.").JsonDocumentService.Update/Removewhen id missing ⇒KeyNotFoundException("…was not found.").GetByGroupon nonexistent group ⇒KeyNotFoundException("JSON Document group '…' does not exist.").TryAdd,TryUpdate,TrySoftRemovecatch and returnfalseinstead of throwing.
Best practices¶
- Use selectors to avoid shipping large JSONs to clients. Prefer explicit, absolute paths (
$.a.b.c) and avoid wildcards. - Soft delete: if you enable it, decide whether your repository should filter
Deleted != nullby default. - Metadata vs Data: put indexable or frequently filtered fields in
Metadata; keep dynamic blob inData. - Authorization: if you depend on
ClaimsPrincipal, honor it in your repository (e.g., filter by tenant/group). - Events: subscribe to
Added/Updated/Deletedfor cache busting or downstream processing. - Validation:
JsonDocumentalready validates JSON—no need to revalidate in your repo unless you transform.
That’s it. Drop in a repository, wire up JsonDocumentService<TId>, and you’ve got a compact, group-aware JSON store with selective projection for your APIs and UIs.