ADR 0020: AuditEvent Hybrid Payload Format
Status
Accepted
Context and Problem Statement
Milestone 2 introduces an append-only audit log that records every significant
state change on a Ticket: creation, status transitions, assignee changes, and
comments. Each AuditEvent must be queryable by ticket and time range, but the
data that makes sense to store per event varies significantly by event type.
The central tension is between two competing needs:
- Query efficiency. Fields used in
WHEREandORDER BYclauses (ticket reference, timestamp, event type) benefit from being first-class typed columns with indexes. - Schema flexibility. Event-specific details vary in shape per event type and will grow as new event types are introduced. Forcing every possible field into a fixed schema creates either sparse rows or a migration every time a new event type ships.
An additional constraint: persistence is SQLite in Milestone 2 and PostgreSQL (via Dapper) in Milestone 3. The storage representation must work for both without requiring changes to the Domain or Application layers.
Decision Drivers
- Audit events are append-only — they are never updated after insert.
- The primary read path is "all events for a ticket, ordered by time" — no cross-ticket payload queries are expected in Milestone 2.
- New event types must not require a schema migration.
- The Domain and Application layers must have no knowledge of JSON or any serialisation format.
- The Web UI must be able to render event-specific details in a type-safe way without parsing raw JSON strings.
Considered Options
Option A — Fully typed columns per event type
Each event type gets its own table or a set of nullable columns representing
its payload fields. For example: StatusChangedFrom, StatusChangedTo,
NewAssignee, PreviousAssignee, CommentAuthor, CommentContent.
Option B — Hybrid model: typed core fields + JSON payload (Selected)
Fields used in queries are normal typed columns. Event-specific data is stored
as a JSON string (TEXT in SQLite, JSONB in PostgreSQL). A single
AuditEvents table covers all event types.
Option C — Pure document store / event store
All fields including the core ones are embedded in a single JSON document per event. No relational columns at all. Requires a document-capable store or significant filtering overhead when querying by ticket or time range.
Decision Outcome
Chosen option: Option B — Hybrid model with typed core fields and JSON payload.
Typed core fields
The following fields are mapped to regular columns with indexes:
| Field | Type | Index | Reason |
|---|---|---|---|
Id |
Guid |
PK | Unique identity |
TicketId |
Guid |
Yes | Primary read filter |
EventType |
string (≤ 100) |
— | Grouping, display, payload discrimination |
Actor |
string (≤ 200) |
— | Who performed the action |
OccurredAt |
DateTimeOffset |
Yes | Chronological ordering |
JSON payload
Event-specific data is stored as a camelCase JSON string in a Payload column
(TEXT in SQLite, JSONB in PostgreSQL). The AuditEventFactory in the
Application layer is the single point of serialisation — the Domain layer never
touches JSON.
Current payload shapes:
// ticket.created
{ "title": "Login page broken", "priority": "High" }
// ticket.status_changed
{ "fromStatus": "New", "toStatus": "Triaged" }
// ticket.assignee_changed
{ "previousAssignee": "alice", "newAssignee": "bob" }
// ticket.comment_added
{ "author": "alice", "content": "Reproduced on Firefox 124." }
Typed Contracts payload hierarchy
Although the payload is stored and transported as a JSON string, the Web UI
must not parse it directly. The Contracts layer defines a typed
AuditEventPayload hierarchy using System.Text.Json's [JsonPolymorphic]
and [JsonDerivedType] attributes:
AuditEventPayload (abstract record, base type)
├── TicketCreatedPayload (Title, Priority)
├── TicketStatusChangedPayload (FromStatus, ToStatus)
├── TicketAssigneeChangedPayload (PreviousAssignee?, NewAssignee?)
├── TicketCommentAddedPayload (Author?, Content)
└── RawAuditEventPayload (RawJson) — catch-all for unknown types
The API layer deserialises the stored JSON string into the appropriate subtype
(keyed on EventType) and serialises the typed object to the HTTP response.
The $type discriminator written by [JsonPolymorphic] allows the Web client
to deserialise directly into the correct concrete type — no EventType string
comparisons or JsonSerializer calls in the UI.
Why this trade-off makes sense for this project
- Query path is unaffected. "All events for ticket X, ordered by
OccurredAt" touches only indexed typed columns — the payload is never read by the database during the primary read. - New event types are additive. Introducing
TicketEscalatedorPriorityChangedrequires adding a factory method, a new[JsonDerivedType]in Contracts, and a branch in the API mapper — no schema migration. - PostgreSQL JSONB is a drop-in upgrade. Changing the column type from
TEXTtoJSONBin Milestone 3 unlocks->>-style queries and GIN indexes without touching the Domain or Application layers. - The Web UI stays a pure consumer.
[JsonPolymorphic]is resolved automatically by both the API serialiser and the Web client's deserialiser because both reference the sameContractsassembly. The Razor template usesis-pattern matching on payload subtypes — no raw JSON, no string comparisons.
Consequences
Positive Consequences
- The primary read path (
GetByTicketId, ordered byOccurredAt) is efficient: indexed columns, no JSON parsing by the database. - New event types ship without schema migrations in either persistence provider.
- The
Contractspayload hierarchy enforces type-safe rendering in the Web UI and will catch broken payload shapes at compile time when Contracts change. - The
RawAuditEventPayloadcatch-all ensures the UI degrades gracefully for event types not yet registered in Contracts.
Negative Consequences
- The payload shape per event type is not enforced at the database level — a malformed JSON string would only surface at read time.
- Payload validation is the Application layer's responsibility (enforced via
AuditEventFactory), not the persistence layer's. - Cross-event payload queries (e.g. "all status changes to
Closed") require JSON functions in SQLite (limited) orJSONBoperators in PostgreSQL — not available until Milestone 3. - Adding a new event type requires coordinated changes in four places:
AuditEventFactory,AuditEventTypes,AuditEventMapping, and the[JsonDerivedType]registration inAuditEventPayload.
Re-evaluation Triggers
Revisit when:
- Milestone 3 (PostgreSQL / Dapper) is implemented — confirm the
Payloadcolumn migrates toJSONBand that no Application-layer changes are needed. - Cross-payload queries are required — if reporting or analytics need to filter or aggregate by payload fields, consider materialising those fields into typed columns or a read-model projection.
- A new event type is introduced — verify the four-file change set is
complete and covered by a test in
AuditEventMappingTests.
Related
- ADR 0001 – Clean / Hexagonal Layered Architecture
- ADR 0007 – Swappable Persistence via Runtime Provider Switch
- ADR 0010 – Versioned HTTP Contracts in a Dedicated
ContractsProject src/ServiceDeskLite.Domain/Audit/AuditEvent.cssrc/ServiceDeskLite.Domain/Audit/AuditEventTypes.cssrc/ServiceDeskLite.Application/Tickets/Audit/AuditEventFactory.cssrc/ServiceDeskLite.Contracts/V1/Tickets/AuditEventPayload.cssrc/ServiceDeskLite.Contracts/V1/Tickets/AuditEventResponse.cssrc/ServiceDeskLite.Api/Mapping/Tickets/AuditEventMapping.cssrc/ServiceDeskLite.Infrastructure/Persistence/Configurations/AuditEventConfiguration.cstests/ServiceDeskLite.Tests.Api/Tickets/AuditEventMappingTests.cs