Skip to content

Audit Wiring

Recipes are working wiring examples with the sharp edges annotated. This page and the audit-wiring recipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.

Two packages compose:

  • @modularityjs/auditAuditService abstract contract + AuditModule. Methods: record(entry: NewAuditEntry) → AuditEntry (validates, stamps id + occurredAt, persists) and find(query?: AuditQuery) → AuditEntry[] (newest-first, criteria ANDed, limit default 100). An entry is { actor?, action, subjectType?, subjectId?, context?, occurredAt? }action is a required dot-namespaced verb ('order.refunded'); actor undefined means "system".
  • @modularityjs/audit-memory (driver — bounded ring buffer for dev/tests, maxEntries default 10 000) or @modularityjs/audit-database-{typeorm,prisma} (extensions — persistent stores over your database driver; TypeORM ships an audit_log entity and registers it in the entities pool, Prisma maps a model, delegate name auditLog by default).

Unlike metrics, audit write failures propagate — an entry that can't be persisted is a compliance event the caller must handle, not a droppable side channel. Auditing is an explicit call from the service performing the operation (there is no decorator or HTTP hook): that's where actor and subject are known precisely.

typescript
import { AuditModule, AuditService } from '@modularityjs/audit';
import { AuditMemoryModule } from '@modularityjs/audit-memory';
import { Inject, Injectable } from '@modularityjs/di';
import { inversify } from '@modularityjs/di-inversify';
import { createApp, Module, ModularityModule } from '@modularityjs/modularity';

@Injectable()
class OrderService {
  constructor(@Inject(AuditService) private readonly audit: AuditService) {}

  async refund(orderId: string, actorId: string): Promise<void> {
    // ...perform the refund first; audit the completed fact...
    await this.audit.record({
      actor: actorId,
      action: 'order.refunded',
      subjectType: 'order',
      subjectId: orderId,
      context: { reason: 'customer-request' },
    });
  }

  async history(orderId: string) {
    return this.audit.find({ subjectType: 'order', subjectId: orderId });
  }
}

@Module({
  name: 'orders',
  imports: [AuditModule],
  providers: [OrderService],
})
class OrdersModule {}

const app = await createApp({
  di: inversify,
  modules: [ModularityModule, AuditModule, AuditMemoryModule, OrdersModule],
});

Store switch is one line. Replace AuditMemoryModule with AuditDatabaseTypeormModule (next to DatabaseModule + DatabaseTypeormModule.forRoot({...})) or AuditDatabasePrismaModuleOrderService is unchanged. Memory is for dev/tests only; a compliance trail needs a database store.

Record identifiers, never payloads. Audit entries outlive operational data and context is stored verbatim — PII written there becomes a GDPR erasure problem inside a trail that must not be edited. subjectId: order.id, not the customer's address. Logger redaction does NOT apply — audit bypasses the logger entirely.

Name actions as <subject>.<verb-past-tense> (user.suspended, invoice.voided) and keep the vocabulary flat and greppable — find({ action }) is an exact match, not a prefix search.