AuditEvent, AuditLogger, and AuditEventListener using the Observer pattern so multiple sinks (DB, file, external SIEM) consume the same audit stream, and how the class design enforces immutability — distinct from a general-purpose logging framework.
Published September 23, 2026
This is a distinct exercise from Design a Logging Framework — that one covers GENERAL-PURPOSE application logging (log levels, appenders, formatters, routing by severity). An audit log serves a fundamentally different purpose: an immutable, trustworthy record of WHO did WHAT and WHEN, for compliance and dispute resolution — closer in spirit to Payment — Requirements' auditability point than to debug/info log output.
final class AuditEvent { // final, and every field final — see immutability below
final String actorId; final String action; final String resourceId;
final Instant timestamp; final Map<String, String> metadata;
AuditEvent(String actorId, String action, String resourceId, Map<String, String> metadata) {
this.actorId = actorId; this.action = action; this.resourceId = resourceId;
this.timestamp = Instant.now(); this.metadata = Map.copyOf(metadata); // defensive, immutable copy
}
}
interface AuditEventListener { void onEvent(AuditEvent event); }
class AuditLogger {
List<AuditEventListener> listeners = new CopyOnWriteArrayList<>();
void registerListener(AuditEventListener listener) { listeners.add(listener); }
void log(AuditEvent event) { listeners.forEach(l -> l.onEvent(event)); }
}
class DatabaseSink implements AuditEventListener {
public void onEvent(AuditEvent event) { repository.save(event); } // durable, queryable record
}
class SiemSink implements AuditEventListener {
public void onEvent(AuditEvent event) { siemClient.forward(event); } // external security monitoring system
}
A single audit event commonly needs to reach MULTIPLE independent destinations simultaneously — a durable database record for internal compliance queries, AND a forward to an external SIEM (Security Information and Event Management) system for security monitoring, potentially also a file for a separate archival requirement. The Observer pattern (identical in shape to Design a Config Management Client's listener registration) is what lets AuditLogger stay completely unaware of WHICH sinks exist or how many — logging one event automatically reaches every registered listener, and adding a new sink is purely additive, no change needed to AuditLogger itself.
This is the requirement that most distinguishes an audit log from a general logging framework: an audit record's TRUSTWORTHINESS depends on it being genuinely tamper-evident — AuditEvent being final with every field final, and the constructor taking a DEFENSIVE COPY of the mutable metadata map (Map.copyOf, which produces a genuinely immutable map, not just a reference to the caller's own mutable one) means an AuditEvent, once constructed, CANNOT be altered by any code holding a reference to it — not accidentally, and not through any API the class itself exposes. This is a real, deliberate design constraint, not a stylistic preference — an audit record whose fields COULD be mutated after creation would undermine the entire point of having an audit trail at all.
Q: Does immutability at the Java-object level fully guarantee the audit record can't be tampered with? A: No — it prevents tampering via NORMAL application code holding a reference to the object, but the DATABASE record itself, once persisted, could still theoretically be altered directly (a rogue DBA, a compromised credential) — genuinely tamper-EVIDENT audit trails at the storage layer typically add cryptographic techniques (a hash chain linking each record to the previous one, so any alteration breaks the chain and is detectable) beyond what in-memory object immutability alone provides.
Q: Should a failed sink (e.g. the SIEM forward fails) block the DatabaseSink from succeeding?
A: No — each listener should be invoked independently, with one listener's failure isolated from the others (a try/catch around each onEvent call within the dispatch loop, logging the sink-level failure separately) — an audit event failing to reach the SIEM shouldn't prevent it from at least being durably recorded in the database, since losing the record entirely is a worse outcome than one sink temporarily missing it.
Q: How does this framework's immutability requirement compare to Design a Logging Framework's design? A: The general logging framework has no such requirement — a debug log line has no compliance/dispute-resolution role, so there's no need for its class design to defensively guard against mutation; this is precisely the distinction that makes these two exercises genuinely different problems despite superficially similar-sounding names (both 'log' something), not a duplicate of each other.
Q: Should AuditLogger itself validate that an event has all required fields before dispatching?
A: Yes, reasonably — validating at CONSTRUCTION time (in AuditEvent's constructor, rejecting a null actorId or action immediately) is generally preferable to validating later at dispatch time, since it fails fast at the point of creation rather than allowing an incomplete event to exist at all, even briefly.