Skip to content

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 from BaseGroupedEntity<TId>.
  • DateTime? DateTime — optional timestamp for time-range queries.
  • DateTime? Deleted — soft-delete marker (set by the service’s TrySoftRemove).
  • Filter(string[] selectors)destructively replaces Data with a projection (see “Data selectors”).

JSON validation uses System.Text.Json under 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 ClaimsPrincipal as your domain requires.
  • Apply data selectors before returning documents (recommended; see below).
  • Decide soft-delete behavior: either exclude Deleted != null by 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

  • Add throws if id exists; TryAdd swallows and returns false.
  • Update throws if id missing; TryUpdate swallows and returns false.
  • AddOrUpdate does the right thing on existence.
  • GetByGroup throws if the group doesn’t exist (based on repository’s ContainsGroup).

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 uses SelectToken (single token) rather than SelectTokens (multiple). You’ll typically get the first match or null.
  • 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 use SelectTokens and 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, and DateTime, store Data as 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:

  • Added and Updated automatically emit NotificationEntry with:
    • 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 with jsonDocumentId.

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.Add when id exists ⇒ ArgumentException("…already exists.").
  • JsonDocumentService.Update/Remove when id missing ⇒ KeyNotFoundException("…was not found.").
  • GetByGroup on nonexistent group ⇒ KeyNotFoundException("JSON Document group '…' does not exist.").
  • TryAdd, TryUpdate, TrySoftRemove catch and return false instead 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 != null by default.
  • Metadata vs Data: put indexable or frequently filtered fields in Metadata; keep dynamic blob in Data.
  • Authorization: if you depend on ClaimsPrincipal, honor it in your repository (e.g., filter by tenant/group).
  • Events: subscribe to Added/Updated/Deleted for cache busting or downstream processing.
  • Validation: JsonDocument already 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.