Auditing with Mediator Pipelines in ASP.NET Core

Audit commands and store events with transversal behavior

When implementing a web application, it can be a good idea to enforce some kind of auditing to all of your client interactions, either to track their behavior over time, to ensure any security breach will be properly logged, or just to help analyze system bugs.

Previously we talked about using the mediator pattern to implement the Command Query Responsibility Segregation (CQRS) and Event Sourcing (ES) and how pipelines could be used to implement transversal behavior to your application.

Since commands are responsible to mutate the system state, in this article I’m going to demonstrate how you could implement an audit pipeline to ensure all commands will be stored into a table. Because a variable number of events can be broadcasted when the state changes, the pipeline will also store them into another table and with a reference to the command, ensuring any correlation can be analyzed.


The project

From my previous articles, were I explained how to use the mediator and implement transversal behavior with pipelines, we are going to continue and expand the source code to audit commands and store into the events table anything broadcasted by the mediator without having a specific handler for each event.

As a reminder, we implemented an endpoint to manage products with the following:

GET /products — search for products using some filters (SearchProductsQuery);
GET /products/{id} — get a product by its unique identifier (GetProductByIdQuery);
POST /products — create a product (CreateProductCommand and CreatedProductEvent);
PUT /products/{id} — update a product by its unique identifier (UpdateProductCommand and UpdatedProductEvent);
DELETE /products/{id} — delete a product by its unique identifier (DeleteProductCommand and DeletedProductEvent);

You can check them out here:

The source code is available on GitHub, feel free to give it a look.

Auditing

Since in this article we will only audit API actions that mutate state, we are going to intercept commands and store information we find relevant into a specific table:

  • ExternalId — the unique identifier for each command, available via Command.Id or Command<TResult>.Id;
  • Name — the command type name from typeof(TCommand).Name;
  • Payload — the command serialized as JSON;
  • Result — if available, the command result serialized as JSON;
  • CreatedOn — date and time when the command was sent into the mediator, available via Command.CreatedOn or Command<TResult>.CreatedOn;
  • CreatedBy — username from the current request user property, available via Command.CreatedBy or Command<TResult>.CreatedBy;
  • ExecutionTime — elapsed time the handler spent processing the command;

Because events are broadcasted by commands, which are now audited into the database, we are also going to extend the events table and introduce the foreign key CommandId, referencing the commands.

The Database Model

Inside the Database folder create a CommandEntity class and add the new CommandId property to the existing EventEntity:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class CommandEntity
{
public long Id { get; set; }
public Guid ExternalId { get; set; }
public string Name { get; set; }
public string Payload { get; set; }
public string Result { get; set; }
public DateTimeOffset CreatedOn { get; set; }
public string CreatedBy { get; set; }
public TimeSpan ExecutionTime { get; set; }
}

public class EventEntity
{
public long Id { get; set; }
public long CommandId { get; set; } // added property
public Guid ExternalId { get; set; }
public string Name { get; set; }
public string Payload { get; set; }
public DateTimeOffset CreatedOn { get; set; }
public string CreatedBy { get; set; }
}

Open the ApiDbContext file, configure the mappings for the CommandEntity and add a required one-to-many relation between this tables:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public class ApiDbContext : DbContext
{
// ...

protected override void OnModelCreating(ModelBuilder builder)
{
// ...

builder.Entity<CommandEntity>(cfg =>
{
cfg.HasKey(e => e.Id);
cfg.HasAlternateKey(e => e.ExternalId);
cfg.HasIndex(e => e.CreatedOn);
cfg.Property(e => e.Id)
.IsRequired()
.ValueGeneratedOnAdd();
cfg.Property(e => e.ExternalId)
.IsRequired();
cfg.Property(e => e.Name)
.IsRequired()
.HasMaxLength(128);
cfg.Property(e => e.Payload)
.IsRequired();
cfg.Property(e => e.Result);
cfg.Property(e => e.CreatedOn)
.IsRequired();
cfg.Property(e => e.CreatedBy)
.IsRequired()
.HasMaxLength(128);
cfg.Property(e => e.ExecutionTime)
.IsRequired();
});

builder.Entity<EventEntity>(cfg =>
{
cfg.HasKey(e => e.Id);
cfg.HasAlternateKey(e => e.ExternalId);
cfg.HasIndex(e => e.CreatedOn);
cfg.Property(e => e.Id)
.IsRequired()
.ValueGeneratedOnAdd();
cfg.HasOne<CommandEntity>()
.WithMany()
.HasForeignKey(e => e.CommandId)
.IsRequired();
cfg.Property(e => e.ExternalId)
.IsRequired();
cfg.Property(e => e.Name)
.IsRequired()
.HasMaxLength(128);
cfg.Property(e => e.Payload)
.IsRequired();
cfg.Property(e => e.CreatedOn)
.IsRequired();
cfg.Property(e => e.CreatedBy)
.IsRequired()
.HasMaxLength(128);
});
}
}

User Information

By design, all POCOs provided in this library are immutable and only provide a protected setter for the properties Id, CreatedOn and CreatedBy. This ensures the developer is free to decide either by immutable commands, queries and events, initializing all properties in the constructor, or to expose a public setter instead.

Since we haven’t made our POCOs immutable, and for demo purposes, we are going to expose a public setter for the CreatedBy property by implementing our own command, query and event classes.

Inside the Handlers folder create a Command.cs, Query.cs and Event.cs files and extend the corresponding Command, Command<TResult>, Query<TResult> and Event classes, creating a new setter ao getter for CreatedBy. Since your classes have the same name than the ones provided by Simplesoft.Mediator, your existing classes will automatically extend from them and expose the new setters without a single change:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class Command : SimpleSoft.Mediator.Command
{
public new string CreatedBy
{
get => base.CreatedBy;
set => base.CreatedBy = value;
}
}

public class Command<TResult> : SimpleSoft.Mediator.Command<TResult>
{
public new string CreatedBy
{
get => base.CreatedBy;
set => base.CreatedBy = value;
}
}

public class Event : SimpleSoft.Mediator.Event
{
public new string CreatedBy
{
get => base.CreatedBy;
set => base.CreatedBy = value;
}
}

public class Query<TResult> : SimpleSoft.Mediator.Query<TResult>
{
public new string CreatedBy
{
get => base.CreatedBy;
set => base.CreatedBy = value;
}
}

The project solution should look like this:

Open your ProductsController.cs file and set the CreatedBy property of all the commands and queries with the property User.Identity.Name.

Please keep in your mind that, since we haven’t configured authentication in this API, the username value will always be null.

After changes, your controller actions should look as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
[Route("products")]
public class ProductsController : ControllerBase
{
private readonly IMediator _mediator;

public ProductsController(IMediator mediator)
{
_mediator = mediator;
}

[HttpGet]
public async Task<IEnumerable<ProductModel>> SearchAsync([FromQuery] string filterQ, [FromQuery] int? skip, [FromQuery] int? take, CancellationToken ct)
{
var result = await _mediator.FetchAsync(new SearchProductsQuery
{
FilterQ = filterQ,
Skip = skip,
Take = take,
CreatedBy = User.Identity.Name
}, ct);

return result.Select(r => new ProductModel
{
Id = r.Id,
Code = r.Code,
Name = r.Name,
Price = r.Price
});
}

[HttpGet("{id:guid}")]
public async Task<ProductModel> GetByIdAsync([FromRoute] Guid id, CancellationToken ct)
{
var result = await _mediator.FetchAsync(new GetProductByIdQuery
{
ProductId = id,
CreatedBy = User.Identity.Name
}, ct);

return new ProductModel
{
Id = result.Id,
Code = result.Code,
Name = result.Name,
Price = result.Price
};
}

[HttpPost]
public async Task<CreateProductResultModel> CreateAsync([FromBody] CreateProductModel model, CancellationToken ct)
{
var result = await _mediator.SendAsync(new CreateProductCommand
{
Code = model.Code,
Name = model.Name,
Price = model.Price,
CreatedBy = User.Identity.Name
}, ct);

return new CreateProductResultModel
{
Id = result.Id
};
}

[HttpPut("{id:guid}")]
public async Task UpdateAsync([FromRoute] Guid id, [FromBody] UpdateProductModel model, CancellationToken ct)
{
await _mediator.SendAsync(new UpdateProductCommand
{
ProductId = id,
Code = model.Code,
Name = model.Name,
Price = model.Price,
CreatedBy = User.Identity.Name
}, ct);
}

[HttpDelete("{id:guid}")]
public async Task DeleteAsync([FromRoute] Guid id, CancellationToken ct)
{
await _mediator.SendAsync(new DeleteProductCommand
{
ProductId = id,
CreatedBy = User.Identity.Name
}, ct);
}
}

We also want to pass the same username to all of our events, so open the command handlers and set the event CreatedBy property with the same value from the command, as exemplified by the following handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class CreateProductCommandHandler : ICommandHandler<CreateProductCommand, CreateProductResult>
{
private readonly ApiDbContext _context;
private readonly IMediator _mediator;

public CreateProductCommandHandler(ApiDbContext context, IMediator mediator)
{
_context = context;
_mediator = mediator;
}

public async Task<CreateProductResult> HandleAsync(CreateProductCommand cmd, CancellationToken ct)
{
var products = _context.Set<ProductEntity>();

if (await products.AnyAsync(p => p.Code == cmd.Code, ct))
{
throw new InvalidOperationException($"Product code '{cmd.Code}' already exists");
}

var externalId = Guid.NewGuid();
await products.AddAsync(new ProductEntity
{
ExternalId = externalId,
Code = cmd.Code,
Name = cmd.Name,
Price = cmd.Price
}, ct);

await _mediator.BroadcastAsync(new CreatedProductEvent
{
ExternalId = externalId,
Code = cmd.Code,
Name = cmd.Name,
Price = cmd.Price,
CreatedBy = cmd.CreatedBy // use the same value
}, ct);

return new CreateProductResult
{
Id = externalId
};
}
}

The Audit Pipeline

Now that we are passing the user information into the mediator we can create the audit pipeline that will have the following behavior when intercepting commands:

  1. Serialize and insert a new entry into the commands table;
  2. Add both the command and entry ids into an AsyncLocal<T> scope to be used if an event is broadcast;
  3. Invoke the next pipe;
  4. If available, serialize the result, calculate the execution time and update the table entry;

When intercepting events, which are sent by commands, it will do the following:

  1. Get the command id from the current AsyncLocal<T> scope;
  2. Serialize the event and insert a new entry into the events table, referencing the command entry;
  3. Invoke the next pipe;

Inside the Pipelines folder, create an AuditPipeline class extending Pipeline. The implementation should be similar to the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
public class AuditPipeline : Pipeline
{
private readonly DbSet<CommandEntity> _commands;
private readonly DbSet<EventEntity> _events;

public AuditPipeline(ApiDbContext context)
{
_commands = context.Set<CommandEntity>();
_events = context.Set<EventEntity>();
}

public override async Task OnCommandAsync<TCommand>(Func<TCommand, CancellationToken, Task> next, TCommand cmd, CancellationToken ct)
{
var command = (await _commands.AddAsync(new CommandEntity
{
ExternalId = cmd.Id,
Name = typeof(TCommand).Name,
Payload = JsonSerializer.Serialize(cmd),
Result = null,
CreatedOn = cmd.CreatedOn,
CreatedBy = cmd.CreatedBy,
ExecutionTime = TimeSpan.Zero
}, ct)).Entity;

using (CommandScope.Begin(command.ExternalId, command.Id))
{
await next(cmd, ct);
}

command.ExecutionTime = DateTimeOffset.UtcNow - cmd.CreatedOn;
}

public override async Task<TResult> OnCommandAsync<TCommand, TResult>(Func<TCommand, CancellationToken, Task<TResult>> next, TCommand cmd, CancellationToken ct)
{
var command = (await _commands.AddAsync(new CommandEntity
{
ExternalId = cmd.Id,
Name = typeof(TCommand).Name,
Payload = JsonSerializer.Serialize(cmd),
Result = null,
CreatedOn = cmd.CreatedOn,
CreatedBy = cmd.CreatedBy,
ExecutionTime = TimeSpan.Zero
}, ct)).Entity;

TResult result;

using (CommandScope.Begin(command.ExternalId, command.Id))
{
result = await next(cmd, ct);
}

command.Result = result == null ? null : JsonSerializer.Serialize(result);
command.ExecutionTime = DateTimeOffset.UtcNow - cmd.CreatedOn;

return result;
}

public override async Task OnEventAsync<TEvent>(Func<TEvent, CancellationToken, Task> next, TEvent evt, CancellationToken ct)
{
await _events.AddAsync(new EventEntity
{
CommandId = CommandScope.Current.Id,
ExternalId = evt.Id,
Name = typeof(TEvent).Name,
Payload = JsonSerializer.Serialize(evt),
CreatedOn = evt.CreatedOn,
CreatedBy = evt.CreatedBy,
}, ct);

await next(evt, ct);
}

private class CommandScope : IDisposable
{
private CommandScope(Guid externalId, long id)
{
ExternalId = externalId;
Id = id;

AsyncLocal.Value = this;
}

public Guid ExternalId { get; }

public long Id { get; }

public void Dispose()
{
AsyncLocal.Value = null;
}

private static readonly AsyncLocal<CommandScope> AsyncLocal = new AsyncLocal<CommandScope>();

public static CommandScope Current => AsyncLocal.Value;

public static IDisposable Begin(Guid externalId, long id) => new CommandScope(externalId, id);
}
}

Open the Startup.cs file and register this pipeline to be run after all the existing ones, right before the commands or events are handled.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();

services.AddSwaggerGen();

services.AddDbContext<ApiDbContext>(o =>
{
o.UseInMemoryDatabase("ApiDbContext").ConfigureWarnings(warn =>
{
// since InMemoryDatabase does not support transactions
// for test purposes we are going to ignore this exception
warn.Ignore(InMemoryEventId.TransactionIgnoredWarning);
});
});

services.AddMediator(o =>
{
o.AddPipeline<LoggingPipeline>();
o.AddPipeline<TimeoutPipeline>();
o.AddPipeline<ValidationPipeline>();
o.AddPipeline<TransactionPipeline>();
o.AddPipeline<AuditPipeline>();
foreach (var implementationType in typeof(Startup)
.Assembly
.ExportedTypes
.Where(t => t.IsClass && !t.IsAbstract))
{
foreach (var serviceType in implementationType
.GetInterfaces()
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IValidator<>)))
{
o.Services.Add(new ServiceDescriptor(serviceType, implementationType, ServiceLifetime.Transient));
}
}
o.AddHandlersFromAssemblyOf<Startup>();
});

// or, if using the SimpleSoft.Mediator.Microsoft.Extensions.* pipelines
//services.AddMediator(o =>
//{
// o.AddPipelineForLogging(options =>
// {
// options.LogCommandResult = true;
// options.LogQueryResult = true;
// });
// o.AddPipeline<TimeoutPipeline>();
// o.AddPipelineForValidation(options =>
// {
// options.ValidateCommand = true;
// options.ValidateEvent = true;
// });
// o.AddPipelineForEFCoreTransaction<ApiDbContext>(options =>
// {
// options.BeginTransactionOnCommand = true;
// });
// o.AddPipeline<AuditPipeline>();

// o.AddValidatorsFromAssemblyOf<Startup>();
// o.AddHandlersFromAssemblyOf<Startup>();
//});
}

// ...
}

Because this pipeline is also serializing all events, the existing handlers for CreatedProductEvent, DeletedProductEvent and UpdatedProductEvent can now either be deleted or stop storing their events into the table to prevent duplicated data:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class CreatedProductEventHandler : IEventHandler<CreatedProductEvent>
{
private readonly ApiDbContext _context;

public CreatedProductEventHandler(ApiDbContext context)
{
_context = context;
}

public Task HandleAsync(CreatedProductEvent evt, CancellationToken ct)
{
//await _context.Set<EventEntity>().AddAsync(new EventEntity
//{
// ExternalId = evt.Id,
// Name = nameof(CreatedProductEvent),
// Payload = JsonSerializer.Serialize(evt),
// CreatedOn = evt.CreatedOn,
// CreatedBy = evt.CreatedBy
//}, ct);

return Task.CompletedTask;
}
}

AsyncLocal

When comparing the audit pipeline with the previous ones we implemented, the biggest difference is the usage of AsyncLocal<T> to store an instance of the CommandScope class holding both the command external id and the primary key value for the audit entry into the table.

If you aren’t familiar with this class, it is available since .NET Framework 4.6 and .NET standard 1.3, and was introduced to help sharing global flow state when implementing asynchronous code with Task Parallel Library (TPL). Because TPL relies on the thread pool and, by default, asynchronous code in ASP.NET Core applications can be resumed by any available thread, we can’t rely on mechanisms like the ThreadLocal class to store global state.

Simply put, the idea of AsyncLocal<T> is to create a static instance that can hold some T value and, as long you use the async and await keywords, the runtime will consider your code execution to be a logical flow, despite asynchronous, and will ensure the value is shared even if the flow has been resumed by a different thread.

Because we want to share data between the command and event interceptor code, the flow is asynchronous, and since only commands broadcast events, the AsyncLocal<T> class is an elegant solution to prevent changing all the events to include an CommandId property that has to be set on every broadcast.

As an example, this is usually the solution implemented by some logging frameworks to support the creation of scopes, enabling some information to be written on every log without having to pass it every time, like when the using Microsoft façade Logger.BeginScope("X:{x} Y:{y}", x, y).

For more details and examples, give a look to the AsyncLocal<T> class documentation.

Audits Controller

To make it easier to test and check our system audits, we are going to implement the following endpoint:

GET /audits — search for command audits using some filters (SearchAuditsQuery);
GET /audits/{id} — get a command audit by its unique identifier and all the associated events (GetAuditByIdQuery);

Inside the Handlers folder create an Audits folder and create the queries for searching or getting an audit by its external id:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
public class SearchAuditsQueryHandler : IQueryHandler<SearchAuditsQuery, IEnumerable<AuditSearchItem>>
{
private readonly IQueryable<CommandEntity> _commands;

public SearchAuditsQueryHandler(ApiDbContext context)
{
_commands = context.Set<CommandEntity>();
}

public async Task<IEnumerable<AuditSearchItem>> HandleAsync(SearchAuditsQuery query, CancellationToken ct)
{
var filter = _commands;

if (!string.IsNullOrWhiteSpace(query.FilterQ))
{
var filterQ = query.FilterQ.Trim();

filter = filter.Where(p =>
p.Name.Contains(filterQ)
);
}

var skip = query.Skip ?? 0;
var take = query.Take ?? 20;

return await filter
.OrderByDescending(c => c.CreatedOn)
.ThenByDescending(c => c.Id)
.Skip(skip)
.Take(take)
.Select(c => new AuditSearchItem
{
Id = c.ExternalId,
Name = c.Name,
CreatedOn = c.CreatedOn,
CreatedBy = c.CreatedBy,
ExecutionTimeInMs = (long) c.ExecutionTime.TotalMilliseconds
}).ToListAsync(ct);
}
}

public class SearchAuditsQuery : Query<IEnumerable<AuditSearchItem>>
{
public string FilterQ { get; set; }
public int? Skip { get; set; }
public int? Take { get; set; }
}

public class AuditSearchItem
{
public Guid Id { get; set; }
public string Name { get; set; }
public DateTimeOffset CreatedOn { get; set; }
public string CreatedBy { get; set; }
public long ExecutionTimeInMs { get; set; }
}

public class GetAuditByIdQueryHandler : IQueryHandler<GetAuditByIdQuery, Audit>
{
private readonly IQueryable<CommandEntity> _commands;
private readonly IQueryable<EventEntity> _events;

public GetAuditByIdQueryHandler(ApiDbContext context)
{
_commands = context.Set<CommandEntity>();
_events = context.Set<EventEntity>();
}

public async Task<Audit> HandleAsync(GetAuditByIdQuery query, CancellationToken ct)
{
var command = await _commands.SingleOrDefaultAsync(c => c.ExternalId == query.AuditId, ct);

if (command == null)
{
throw new InvalidOperationException($"Command audit '{query.AuditId}' not found");
}

var events = await _events.Where(e => e.CommandId == command.Id).ToListAsync(ct);

return new Audit
{
Id = command.ExternalId,
Name = command.Name,
Payload = JsonSerializer.Deserialize<dynamic>(command.Payload),
Result = string.IsNullOrWhiteSpace(command.Result)
? null
: JsonSerializer.Deserialize<dynamic>(command.Result),
CreatedOn = command.CreatedOn,
CreatedBy = command.CreatedBy,
ExecutionTimeInMs = (long) command.ExecutionTime.TotalMilliseconds,
Events = events.Select(e => new AuditEvent
{
Id = e.ExternalId,
Name = e.Name,
Payload = JsonSerializer.Deserialize<dynamic>(e.Payload),
CreatedOn = e.CreatedOn
}).ToList()
};
}
}

public class GetAuditByIdQuery : Query<Audit>
{
public Guid AuditId { get; set; }
}

public class Audit
{
public Guid Id { get; set; }
public string Name { get; set; }
public object Payload { get; set; }
public object Result { get; set; }
public DateTimeOffset CreatedOn { get; set; }
public string CreatedBy { get; set; }
public long ExecutionTimeInMs { get; set; }
public IEnumerable<AuditEvent> Events { get; set; }
}

public class AuditEvent
{
public Guid Id { get; set; }
public string Name { get; set; }
public object Payload { get; set; }
public DateTimeOffset CreatedOn { get; set; }
}

Inside the Controllers folder create an Audit folder and an AuditController that will use the previous queries:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
[Route("audits")]
public class AuditsController : ControllerBase
{
private readonly IMediator _mediator;

public AuditsController(IMediator mediator)
{
_mediator = mediator;
}

[HttpGet]
public async Task<IEnumerable<AuditSearchItemModel>> SearchAsync([FromQuery] string filterQ, [FromQuery] int? skip, [FromQuery] int? take, CancellationToken ct)
{
var result = await _mediator.FetchAsync(new SearchAuditsQuery
{
FilterQ = filterQ,
Skip = skip,
Take = take,
CreatedBy = User.Identity.Name
}, ct);

return result.Select(r => new AuditSearchItemModel
{
Id = r.Id,
Name = r.Name,
CreatedOn = r.CreatedOn,
CreatedBy = r.CreatedBy,
ExecutionTimeInMs = r.ExecutionTimeInMs
});
}

[HttpGet("{id:guid}")]
public async Task<AuditModel> GetByIdAsync([FromRoute] Guid id, CancellationToken ct)
{
var result = await _mediator.FetchAsync(new GetAuditByIdQuery
{
AuditId = id,
CreatedBy = User.Identity.Name
}, ct);

return new AuditModel
{
Id = result.Id,
Name = result.Name,
Payload = result.Payload,
Result = result.Result,
CreatedOn = result.CreatedOn,
CreatedBy = result.CreatedBy,
ExecutionTimeInMs = result.ExecutionTimeInMs,
Events = result.Events.Select(e => new AuditEventModel
{
Id = e.Id,
Name = e.Name,
Payload = e.Payload,
CreatedOn = e.CreatedOn
})
};
}
}

public class AuditSearchItemModel
{
public Guid Id { get; set; }
public string Name { get; set; }
public DateTimeOffset CreatedOn { get; set; }
public string CreatedBy { get; set; }
public long ExecutionTimeInMs { get; set; }
}

public class AuditModel
{
public Guid Id { get; set; }
public string Name { get; set; }
public object Payload { get; set; }
public object Result { get; set; }
public DateTimeOffset CreatedOn { get; set; }
public string CreatedBy { get; set; }
public long ExecutionTimeInMs { get; set; }
public IEnumerable<AuditEventModel> Events { get; set; }
}

public class AuditEventModel
{
public Guid Id { get; set; }
public string Name { get; set; }
public object Payload { get; set; }
public DateTimeOffset CreatedOn { get; set; }
}

The project structure should look as follows:

Open the Swagger endpoint (ex: https://localhost:44380/swagger) and you should see the audits endpoint:

Create, update or delete products with the help of Swagger UI and then check if all the commands and events have been properly audited:

And even get the details of a specific audit:

Conclusion

I hope this article gave you a good idea on how to use mediator pipelines to simplify the auditing of user actions without having to replicate code across all commands.

We also ensured events were always stored before being broadcasted and a reference to the command was kept without adding properties to our POCOs, providing a more clean approach.

Soon I’ll be explaining how we can inject more specialized interfaces, like the ISender<TCommand>, to make our dependencies more clearer and help with unit testing.