Skip to content

IoT Device Management

This guide covers Ring 1 of Granit.IoT — the core device-management stack. Four packages, all framework-aligned: a DDD aggregate, a Minimal API surface, an isolated EF Core DbContext, and a PostgreSQL provider that ships the indexes and partitioning helpers EF Core cannot emit on its own.

Without a shared device model, every team rebuilds the same plumbing — and most rebuilds leak in one of four ways:

  • Ad-hoc lifecycle. Devices jump from “new” to “broken” with no audit trail of who suspended them or why. ISO 27001 asset traceability fails the next audit.
  • Leaky multi-tenancy. A missing WHERE tenant_id = … eventually returns another customer’s devices. The fix is not discipline; it’s a named query filter at the EF Core level.
  • Credentials in the response body. Device secrets end up serialised into JSON responses or OpenAPI examples.
  • Slow telemetry queries. A B-tree on recorded_at grows unbounded; GDPR erasure becomes a multi-minute DELETE.

Ring 1 ships an opinionated domain model with private setters, a state machine enforced in code, [SensitiveData] on credentials, and time-series indexes (BRIN + GIN + partitioning) as first-class migration helpers.

  • Directorysrc/
    • DirectoryGranit.IoT/ Aggregates, value objects, events, CQRS abstractions, diagnostics
    • DirectoryGranit.IoT.Endpoints/ Minimal API: /iot/devices + /iot/telemetry
    • DirectoryGranit.IoT.EntityFrameworkCore/ IoTDbContext, query filters, reader/writer impls
    • DirectoryGranit.IoT.EntityFrameworkCore.Postgres/ BRIN, GIN, JSONB, RANGE partitioning helpers

Device is a FullAuditedAggregateRoot implementing IMultiTenant, IWorkflowStateful, ITimelined, and IConcurrencyAware. Every property has a private set — all mutations go through methods enforced by architecture tests, and IConcurrencyAware backs the optimistic-concurrency stamp used by the update endpoint.

stateDiagram-v2
  [*] --> Provisioning : Device.Create(...)
  Provisioning --> Active : Activate()
  Active --> Suspended : Suspend(reason)
  Suspended --> Active : Reactivate()
  Provisioning --> Decommissioned : Decommission()
  Suspended --> Decommissioned : Decommission()
  Decommissioned --> [*]

There is no direct Active → Decommissioned edge: Decommission() throws on an Active device, forcing operators to Suspend(reason) first so the audit log always carries a reason. Decommissioning is legal from Provisioning or Suspended.

Each transition raises a domain event (DeviceProvisionedEvent, DeviceActivatedEvent, DeviceSuspendedEvent, DeviceReactivatedEvent, DeviceDecommissionedEvent) consumed locally by handlers. Cross-boundary events carry the *Eto suffix — for example DeviceProvisionedEto, emitted through the integration bus.

Illegal transitions throw a domain exception. device.Activate() on a Decommissioned device fails loudly, not silently.

public static Device Create(
Guid id, Guid? tenantId,
DeviceSerialNumber serialNumber, HardwareModel model, FirmwareVersion firmware,
string? label = null, DeviceCredential? credential = null);
public void Activate();
public void Suspend(string reason);
public void Reactivate();
public void Decommission();
public void UpdateFirmware(FirmwareVersion firmware);
public void UpdateLabel(string? label);
public void UpdateCredential(DeviceCredential? credential);
public void UpdateTags(IReadOnlyDictionary<string, string>? tags);
public void RecordHeartbeat(DateTimeOffset timestamp);
TypeBaseValidation
DeviceSerialNumberSingleValueObject<string>^[A-Za-z0-9][A-Za-z0-9\-_]{0,127}$ — leading alphanumeric, then mixed-case alphanumeric, dash, or underscore; max 128 chars
HardwareModelSingleValueObject<string>Non-empty, length-bounded
FirmwareVersionSingleValueObject<string>Semver-like, length-bounded
DeviceCredentialValueObjectCredentialType + ProtectedSecret ([SensitiveData])
MetricNameSingleValueObject<string>Dot-notation, lowercase, max 10 segments

DeviceCredential.ProtectedSecret carries [SensitiveData(Level = Sensitivity.Restricted, Mode = SensitiveDataMode.Omit)] from the Granit.DataProtection namespace — the data-protection pipeline omits it from logs, serialization, OpenAPI schemas, and validation-error payloads. The stored value is caller-supplied ciphertext; see Secrets at rest.

The reader and writer interfaces are never merged into a *Store. Readers query with AsNoTracking(); writers mutate; heartbeat updates bypass the full aggregate load via ExecuteUpdateAsync.

public interface IDeviceReader
{
Task<Device?> FindAsync(Guid id, CancellationToken ct = default);
Task<Device?> FindBySerialNumberAsync(string serialNumber, CancellationToken ct = default);
Task<bool> ExistsAsync(string serialNumber, CancellationToken ct = default);
Task<IReadOnlyList<Guid?>> GetDistinctTenantIdsAsync(CancellationToken ct = default);
Task<IReadOnlyList<Device>> FindStaleAsync(
IReadOnlyCollection<Guid?> tenantIds,
DateTimeOffset lastHeartbeatBefore,
int batchSize,
CancellationToken ct = default);
}
public interface IDeviceWriter
{
Task AddAsync(Device device, CancellationToken ct = default);
Task UpdateAsync(Device device, CancellationToken ct = default);
Task UpdateAsync(Device device, string originalConcurrencyStamp, CancellationToken ct = default);
Task DeleteAsync(Device device, CancellationToken ct = default);
Task UpdateHeartbeatAsync(Guid deviceId, DateTimeOffset timestamp, CancellationToken ct = default);
}

The reader intentionally has no ListAsync / CountAsync: paging, filtering, sorting, and search all go through the MapGranitQuery<Device>() list endpoint (GET /iot/devices), not the CQRS reader — see Reporting & query surface. The reader keeps only the targeted lookups (FindAsync, FindBySerialNumberAsync, ExistsAsync) plus the two cross-tenant batch queries (GetDistinctTenantIdsAsync, FindStaleAsync) that the background jobs depend on.

The second UpdateAsync overload takes the client’s originalConcurrencyStamp and raises a 409 Conflict when it no longer matches the stored value — it is the write path behind the optimistic-concurrency flow.

UpdateHeartbeatAsync emits one UPDATE iot_devices SET last_heartbeat_at = $1 WHERE id = $2 via EF Core’s bulk update — no SELECT round-trip. At 100k devices publishing every 10 minutes, this keeps the write path predictable.

FindStaleAsync is the multi-tenant batch signature consumed by DeviceHeartbeatTimeoutJob. It bypasses the tenant query filter via IgnoreQueryFilters() and uses WHERE tenant_id = ANY(@list).

Granit.IoT.Endpoints registers the route group via MapGranitIoTEndpoints():

MethodRoutePermissionReturns
GET/iot/devicesIoT.Devices.Read200 Ok — paged PagedResult (filter / sort / search / cursor) — see Reporting & query surface
GET/iot/devices/metaIoT.Devices.Read200 Ok — column schema the React grid binds to
GET/iot/devices/{id}IoT.Devices.Read200 OkDeviceResponse / 404 Not Found
POST/iot/devicesIoT.Devices.Manage201 CreatedDeviceResponse
PUT/iot/devices/{id}IoT.Devices.Manage200 Ok / 409 on ConcurrencyStamp mismatch
DELETE/iot/devices/{id}IoT.Devices.Manage204 No Content / 409 when still Active

The GET /iot/devices list and GET /iot/devices/meta schema routes are mounted by MapGranitQuery<Device>(); the item CRUD routes above them are the module’s own handlers.

public sealed record DeviceProvisionRequest
{
public required string SerialNumber { get; init; }
public required string HardwareModel { get; init; }
public required string FirmwareVersion { get; init; }
public string? Label { get; init; }
}
public sealed record DeviceResponse(
Guid Id,
string SerialNumber,
string HardwareModel,
string FirmwareVersion,
string Status,
string? Label,
DateTimeOffset? LastHeartbeatAt,
DateTimeOffset CreatedAt,
DateTimeOffset? ModifiedAt,
string ConcurrencyStamp);
public sealed record DeviceUpdateRequest : IConcurrencyStampRequest
{
[Required(AllowEmptyStrings = false)]
public string ConcurrencyStamp { get; init; } = null!;
public string? FirmwareVersion { get; init; }
public string? Label { get; init; }
}

FluentValidation rejects malformed requests with 422 Unprocessable Entity before your handler runs. DeviceCredential.ProtectedSecret is never serialised back to the client.

GET /iot/devices/{id} returns a ConcurrencyStamp — an opaque token that tracks the aggregate version. To update a device, echo that stamp back in the DeviceUpdateRequest. Its other fields are optional: null leaves a value unchanged; an empty Label clears it.

The server compares the submitted stamp against the current stored value. If another writer modified the device in the meantime, the stamps differ and the PUT returns 409 Conflict. Re-read the device, reapply your change on top of the fresh stamp, and retry.

sequenceDiagram
  participant Client
  participant API as /iot/devices/{id}
  Client->>API: GET
  API-->>Client: 200 DeviceResponse { ConcurrencyStamp: "v3" }
  Note over Client: another writer updates the device (stamp → "v4")
  Client->>API: PUT { ConcurrencyStamp: "v3", ... }
  API-->>Client: 409 Conflict
  Client->>API: GET
  API-->>Client: 200 DeviceResponse { ConcurrencyStamp: "v4" }
  Client->>API: PUT { ConcurrencyStamp: "v4", ... }
  API-->>Client: 200 Ok

DeviceUpdateRequest implements IConcurrencyStampRequest, so the stamp is [Required] — a missing or empty value fails validation with 422 Unprocessable Entity.

Telemetry query endpoints — /iot/telemetry

Section titled “Telemetry query endpoints — /iot/telemetry”
MethodRoutePermissionNotes
GET/iot/telemetryIoT.Telemetry.ReadPaged list via MapGranitQuery<TelemetryPoint>(); cursor-only (see below)
GET/iot/telemetry/metaIoT.Telemetry.ReadColumn schema the React grid binds to
GET/iot/telemetry/{deviceId}/latestIoT.Telemetry.ReadMost recent telemetry point for the device
GET/iot/telemetry/{deviceId}/aggregateIoT.Telemetry.ReadServer-side Avg / Min / Max / Count for one metric

The legacy path-based GET /iot/telemetry/{deviceId} (with a maxPoints parameter) is retired. Time-range reads are now expressed against the QueryEngine list endpoint:

GET /iot/telemetry?filter[deviceId.eq]={id}&filter[recordedAt.gte]=2026-07-01T00:00:00Z&sort=-recordedAt

Only /latest and /aggregate delegate to ITelemetryReader (GetLatestAsync / GetAggregateAsync); aggregates are computed in PostgreSQL via (metrics->>'<key>')::float, never loaded into memory. The list path runs entirely through Granit.QueryEngine, not the reader. The telemetry group forces skipTotalCount=true (cursor-only navigation — COUNT(*) across time-series partitions is prohibitively expensive) and sets Cache-Control: private, no-store so shared proxies never cache device-attributable data.

Cross-tenant access returns 404 Not Found — the existence of another tenant’s device is never leaked.

Both device and telemetry groups expose a Granit.QueryEngine list + /meta pair, mounted by MapGranitQuery<Device>() and MapGranitQuery<TelemetryPoint>(). This is the read surface the admin grids and dashboards bind to — you never hand-roll a ListAsync.

  • GET / (the list) returns a paged/cursor result and accepts the standard filter[field.op]=value syntax for filtering, sort=-field, search=…, and groupBy=…. Example: GET /iot/devices?filter[status.eq]=Active&sort=-createdAt&search=warehouse.
  • GET /meta returns the column schema — labels, allowed operators, sort options, defaults, and the semantic display hints below — so the React grid renders each cell without an app-side lookup table.

The whitelisted columns are declared in DeviceQueryDefinition and TelemetryPointQueryDefinition. Value-object and JSONB columns are deliberately left off the whitelist, and the credential is never exposed.

Columns carry ValueKind annotations — pure display hints the front-end grid uses to pick a cell renderer, with no effect on filtering or storage:

DefinitionColumnValueKindRenders as
DeviceQueryDefinitionStatusEnumStatus badge / enum picker
DeviceQueryDefinitionLastHeartbeatAtRelativeTime”5 min ago” (vs. absolute CreatedAt / ModifiedAt)
TelemetryPointQueryDefinitionMessageIdIdentifierMonospace, copy-friendly id

DeviceQueryDefinition calls AllowGroupBy(d => d.Status), so GET /iot/devices?groupBy=status returns a grouped result — the data behind a “fleet grouped by device status” dashboard chart. Telemetry stays deliberately un-groupable (grouping a billion-row time-series is a footgun).

DeviceQueryDefinition registers itself as a data-lookup source via .AsLookup("devices", d => d.Id, d => d.Label ?? d.SerialNumber.Value, …), wired at runtime by AddQueryDefinitionLookup<Device, IoTDbContext>() inside AddGranitIoTEntityFrameworkCore. Any other module’s grid can then declare a .Lookup("devices") column — and the @-mention picker resolves device references — against a real, tenant-scoped, permission-checked (IoT.Devices.Read) typeahead. The telemetry grid’s DeviceId column consumes exactly this source.

TelemetryPoint is a CreationAuditedEntity, never an aggregate. A single device payload ({temp: 22.5, humidity: 45, battery: 90}) is one row with three metrics in the metrics JSONB column.

public static TelemetryPoint Create(
Guid id, Guid deviceId, Guid? tenantId,
DateTimeOffset recordedAt,
IReadOnlyDictionary<string, double> metrics,
string? messageId, string? source);
  • One row vs N rows per payload — 3-10× smaller storage

  • One index lookup per query, not N joins

  • Writes don’t need a transaction to keep multi-metric payloads atomic

  • GIN index on metrics still allows per-key filters:

    WHERE metrics @> '{"temperature": 22.5}'

Granit.IoT ships its own DbContext. No shared context across modules — this keeps migrations independent and architecture tests enforceable.

internal sealed class IoTDbContext(
DbContextOptions<IoTDbContext> options,
ICurrentTenant currentTenant,
IDataFilter? dataFilter = null)
: GranitDbContext(options, currentTenant, dataFilter)
{
public DbSet<Device> Devices => Set<Device>();
public DbSet<TelemetryPoint> TelemetryPoints => Set<TelemetryPoint>();
protected override void OnGranitModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.ConfigureIoTModule();
}

The context derives from GranitDbContext, not raw DbContext — the base class wires the multi-tenancy and soft-delete query filters, the audit interceptors, and the iot_ schema conventions from the injected ICurrentTenant / IDataFilter. Entity configurations are applied by overriding OnGranitModelCreating, which calls modelBuilder.ConfigureIoTModule() (ApplyConfiguration(new DeviceConfiguration()) and its telemetry sibling). The class is internal — hosts register it through AddGranitIoTEntityFrameworkCore (which delegates to AddGranitIsolatedDbContext<IoTDbContext>), never by new-ing it. All tables are prefixed iot_ (iot_devices, iot_telemetry_points).

TableIndexPurpose
iot_devicesUNIQUE (tenant_id, serial_number)Per-tenant serial uniqueness
iot_devices(tenant_id, status)Filter by status within a tenant
iot_telemetry_points(device_id, recorded_at DESC)Covering index for the most common query
iot_telemetry_points(tenant_id, recorded_at)GDPR bulk erasure + per-tenant purge

Granit.IoT.EntityFrameworkCore.Postgres ships MigrationBuilder extensions for the indexes and partitioning EF Core cannot emit declaratively:

migrationBuilder.CreateTelemetryBrinIndex(); // BRIN(recorded_at)
migrationBuilder.CreateTelemetryGinIndex(); // GIN(metrics jsonb_ops)
migrationBuilder.CreateIoTPostgresIndexes(); // BRIN + GIN in one call
migrationBuilder.EnableTelemetryPartitioning(); // Convert to RANGE-partitioned
migrationBuilder.CreateTelemetryPartition(2026, 5); // iot_telemetry_points_2026_05

A BRIN index on recorded_at is an order of magnitude smaller than a B-tree for append-only data — critical at hundreds of millions of rows.

See Operations for the partition-maintenance job that provisions future partitions automatically.

Declared in IoTPermissions and auto-discovered by Granit.Authorization:

KeyGranted action
IoT.Devices.ReadList and retrieve devices
IoT.Devices.ManageProvision, update, decommission
IoT.Telemetry.ReadQuery telemetry and aggregates