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.
What problem does Ring 1 solve?
Section titled “What problem does Ring 1 solve?”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_atgrows unbounded; GDPR erasure becomes a multi-minuteDELETE.
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.
Package layout
Section titled “Package layout”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 — the central aggregate
Section titled “Device — the central aggregate”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.
Lifecycle state machine
Section titled “Lifecycle state machine”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.
Mutating methods
Section titled “Mutating methods”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);Value objects
Section titled “Value objects”| Type | Base | Validation |
|---|---|---|
DeviceSerialNumber | SingleValueObject<string> | ^[A-Za-z0-9][A-Za-z0-9\-_]{0,127}$ — leading alphanumeric, then mixed-case alphanumeric, dash, or underscore; max 128 chars |
HardwareModel | SingleValueObject<string> | Non-empty, length-bounded |
FirmwareVersion | SingleValueObject<string> | Semver-like, length-bounded |
DeviceCredential | ValueObject | CredentialType + ProtectedSecret ([SensitiveData]) |
MetricName | SingleValueObject<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.
CQRS — reader/writer split
Section titled “CQRS — reader/writer split”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).
Minimal API — /iot/devices
Section titled “Minimal API — /iot/devices”Granit.IoT.Endpoints registers the route group via
MapGranitIoTEndpoints():
| Method | Route | Permission | Returns |
|---|---|---|---|
GET | /iot/devices | IoT.Devices.Read | 200 Ok — paged PagedResult (filter / sort / search / cursor) — see Reporting & query surface |
GET | /iot/devices/meta | IoT.Devices.Read | 200 Ok — column schema the React grid binds to |
GET | /iot/devices/{id} | IoT.Devices.Read | 200 Ok — DeviceResponse / 404 Not Found |
POST | /iot/devices | IoT.Devices.Manage | 201 Created — DeviceResponse |
PUT | /iot/devices/{id} | IoT.Devices.Manage | 200 Ok / 409 on ConcurrencyStamp mismatch |
DELETE | /iot/devices/{id} | IoT.Devices.Manage | 204 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.
Request / response shapes
Section titled “Request / response shapes”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.
Optimistic concurrency
Section titled “Optimistic concurrency”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”| Method | Route | Permission | Notes |
|---|---|---|---|
GET | /iot/telemetry | IoT.Telemetry.Read | Paged list via MapGranitQuery<TelemetryPoint>(); cursor-only (see below) |
GET | /iot/telemetry/meta | IoT.Telemetry.Read | Column schema the React grid binds to |
GET | /iot/telemetry/{deviceId}/latest | IoT.Telemetry.Read | Most recent telemetry point for the device |
GET | /iot/telemetry/{deviceId}/aggregate | IoT.Telemetry.Read | Server-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=-recordedAtOnly /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.
Reporting & query surface
Section titled “Reporting & query surface”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 standardfilter[field.op]=valuesyntax for filtering,sort=-field,search=…, andgroupBy=…. Example:GET /iot/devices?filter[status.eq]=Active&sort=-createdAt&search=warehouse.GET /metareturns 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.
Semantic ValueKind hints
Section titled “Semantic ValueKind hints”Columns carry ValueKind
annotations — pure display hints the front-end grid uses to pick a cell renderer,
with no effect on filtering or storage:
| Definition | Column | ValueKind | Renders as |
|---|---|---|---|
DeviceQueryDefinition | Status | Enum | Status badge / enum picker |
DeviceQueryDefinition | LastHeartbeatAt | RelativeTime | ”5 min ago” (vs. absolute CreatedAt / ModifiedAt) |
TelemetryPointQueryDefinition | MessageId | Identifier | Monospace, copy-friendly id |
Grouping — fleet by status
Section titled “Grouping — fleet by status”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).
The "devices" lookup source
Section titled “The "devices" lookup source”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 — append-only ledger
Section titled “TelemetryPoint — append-only ledger”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);Why JSONB-per-payload and not EAV?
Section titled “Why JSONB-per-payload and not EAV?”-
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
metricsstill allows per-key filters:WHERE metrics @> '{"temperature": 22.5}'
IoTDbContext — isolated schema
Section titled “IoTDbContext — isolated schema”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).
Indexes (initial migration)
Section titled “Indexes (initial migration)”| Table | Index | Purpose |
|---|---|---|
iot_devices | UNIQUE (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 |
PostgreSQL-specific helpers
Section titled “PostgreSQL-specific helpers”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 callmigrationBuilder.EnableTelemetryPartitioning(); // Convert to RANGE-partitionedmigrationBuilder.CreateTelemetryPartition(2026, 5); // iot_telemetry_points_2026_05A 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.
Permissions
Section titled “Permissions”Declared in IoTPermissions and auto-discovered by Granit.Authorization:
| Key | Granted action |
|---|---|
IoT.Devices.Read | List and retrieve devices |
IoT.Devices.Manage | Provision, update, decommission |
IoT.Telemetry.Read | Query telemetry and aggregates |
Anti-patterns to avoid
Section titled “Anti-patterns to avoid”See also
Section titled “See also”- Getting started — 5-minute quickstart
- Data model — full PostgreSQL schema, JSONB conventions, sizing
- Telemetry ingestion — how telemetry actually lands in the table
- Time-series storage — PostgreSQL-native vs TimescaleDB hypertables
- Operations — partitioning, purge, heartbeat detection
- Timeline bridge — device lifecycle events as audit chatter