Skip to content

Commit e126c60

Browse files
author
Bart Koelman
committed
Added tests that implement the Transactional Outbox pattern (https://microservices.io/patterns/data/transactional-outbox.html)
1 parent 100a681 commit e126c60

23 files changed

+1911
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Writing.TransactionalOutboxPattern.Messages;
3+
using Microsoft.EntityFrameworkCore;
4+
5+
namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Writing.TransactionalOutboxPattern
6+
{
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
public sealed class DomainDbContext : DbContext
9+
{
10+
public DbSet<DomainUser> Users { get; set; }
11+
public DbSet<DomainGroup> Groups { get; set; }
12+
public DbSet<OutboxMessage> OutboxMessages { get; set; }
13+
14+
public DomainDbContext(DbContextOptions<DomainDbContext> options)
15+
: base(options)
16+
{
17+
}
18+
}
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using System;
2+
using Bogus;
3+
using TestBuildingBlocks;
4+
5+
// @formatter:wrap_chained_method_calls chop_always
6+
// @formatter:keep_existing_linebreaks true
7+
8+
namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Writing.TransactionalOutboxPattern
9+
{
10+
internal sealed class DomainFakers : FakerContainer
11+
{
12+
private readonly Lazy<Faker<DomainUser>> _lazyDomainUserFaker = new Lazy<Faker<DomainUser>>(() =>
13+
new Faker<DomainUser>()
14+
.UseSeed(GetFakerSeed())
15+
.RuleFor(domainUser => domainUser.LoginName, faker => faker.Person.UserName)
16+
.RuleFor(domainUser => domainUser.DisplayName, faker => faker.Person.FullName));
17+
18+
private readonly Lazy<Faker<DomainGroup>> _lazyDomainGroupFaker = new Lazy<Faker<DomainGroup>>(() =>
19+
new Faker<DomainGroup>()
20+
.UseSeed(GetFakerSeed())
21+
.RuleFor(domainGroup => domainGroup.Name, faker => faker.Commerce.Department()));
22+
23+
public Faker<DomainUser> DomainUser => _lazyDomainUserFaker.Value;
24+
public Faker<DomainGroup> DomainGroup => _lazyDomainGroupFaker.Value;
25+
}
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using JetBrains.Annotations;
4+
using JsonApiDotNetCore.Resources;
5+
using JsonApiDotNetCore.Resources.Annotations;
6+
7+
namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Writing.TransactionalOutboxPattern
8+
{
9+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
10+
public sealed class DomainGroup : Identifiable<Guid>
11+
{
12+
[Attr]
13+
public string Name { get; set; }
14+
15+
[HasMany]
16+
public ISet<DomainUser> Users { get; set; }
17+
}
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using JetBrains.Annotations;
7+
using JsonApiDotNetCore.Configuration;
8+
using JsonApiDotNetCore.Middleware;
9+
using JsonApiDotNetCore.Resources;
10+
using JsonApiDotNetCore.Resources.Annotations;
11+
using JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Writing.TransactionalOutboxPattern.Messages;
12+
using Microsoft.EntityFrameworkCore;
13+
14+
namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Writing.TransactionalOutboxPattern
15+
{
16+
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
17+
public sealed class DomainGroupDefinition : JsonApiResourceDefinition<DomainGroup, Guid>
18+
{
19+
private readonly DomainDbContext _dbContext;
20+
private readonly List<OutboxMessage> _pendingMessages = new List<OutboxMessage>();
21+
private string _beforeGroupName;
22+
23+
public DomainGroupDefinition(IResourceGraph resourceGraph, DomainDbContext dbContext)
24+
: base(resourceGraph)
25+
{
26+
_dbContext = dbContext;
27+
}
28+
29+
public override Task OnPrepareWriteAsync(DomainGroup group, OperationKind operationKind, CancellationToken cancellationToken)
30+
{
31+
if (operationKind == OperationKind.CreateResource)
32+
{
33+
group.Id = Guid.NewGuid();
34+
}
35+
else if (operationKind == OperationKind.UpdateResource)
36+
{
37+
_beforeGroupName = group.Name;
38+
}
39+
40+
return Task.CompletedTask;
41+
}
42+
43+
public override async Task OnSetToManyRelationshipAsync(DomainGroup group, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds,
44+
OperationKind operationKind, CancellationToken cancellationToken)
45+
{
46+
if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users))
47+
{
48+
HashSet<Guid> rightUserIds = rightResourceIds.Select(resource => (Guid)resource.GetTypedId()).ToHashSet();
49+
50+
List<DomainUser> beforeUsers = await _dbContext.Users.Include(user => user.Group).Where(user => rightUserIds.Contains(user.Id))
51+
.ToListAsync(cancellationToken);
52+
53+
foreach (DomainUser beforeUser in beforeUsers)
54+
{
55+
IMessageContent content = null;
56+
57+
if (beforeUser.Group == null)
58+
{
59+
content = new UserAddedToGroupContent
60+
{
61+
UserId = beforeUser.Id,
62+
GroupId = group.Id
63+
};
64+
}
65+
else if (beforeUser.Group != null && beforeUser.Group.Id != group.Id)
66+
{
67+
content = new UserMovedToGroupContent
68+
{
69+
UserId = beforeUser.Id,
70+
BeforeGroupId = beforeUser.Group.Id,
71+
AfterGroupId = group.Id
72+
};
73+
}
74+
75+
if (content != null)
76+
{
77+
_pendingMessages.Add(OutboxMessage.CreateFromContent(content));
78+
}
79+
}
80+
81+
if (group.Users != null)
82+
{
83+
foreach (DomainUser userToRemoveFromGroup in group.Users.Where(user => !rightUserIds.Contains(user.Id)))
84+
{
85+
var message = OutboxMessage.CreateFromContent(new UserRemovedFromGroupContent
86+
{
87+
UserId = userToRemoveFromGroup.Id,
88+
GroupId = group.Id
89+
});
90+
91+
_pendingMessages.Add(message);
92+
}
93+
}
94+
}
95+
}
96+
97+
public override async Task OnAddToRelationshipAsync(Guid groupId, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds,
98+
CancellationToken cancellationToken)
99+
{
100+
if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users))
101+
{
102+
HashSet<Guid> rightUserIds = rightResourceIds.Select(resource => (Guid)resource.GetTypedId()).ToHashSet();
103+
104+
List<DomainUser> beforeUsers = await _dbContext.Users.Include(user => user.Group).Where(user => rightUserIds.Contains(user.Id))
105+
.ToListAsync(cancellationToken);
106+
107+
foreach (DomainUser beforeUser in beforeUsers)
108+
{
109+
IMessageContent content = null;
110+
111+
if (beforeUser.Group == null)
112+
{
113+
content = new UserAddedToGroupContent
114+
{
115+
UserId = beforeUser.Id,
116+
GroupId = groupId
117+
};
118+
}
119+
else if (beforeUser.Group != null && beforeUser.Group.Id != groupId)
120+
{
121+
content = new UserMovedToGroupContent
122+
{
123+
UserId = beforeUser.Id,
124+
BeforeGroupId = beforeUser.Group.Id,
125+
AfterGroupId = groupId
126+
};
127+
}
128+
129+
if (content != null)
130+
{
131+
_pendingMessages.Add(OutboxMessage.CreateFromContent(content));
132+
}
133+
}
134+
}
135+
}
136+
137+
public override Task OnRemoveFromRelationshipAsync(DomainGroup group, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds,
138+
CancellationToken cancellationToken)
139+
{
140+
if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users))
141+
{
142+
HashSet<Guid> rightUserIds = rightResourceIds.Select(resource => (Guid)resource.GetTypedId()).ToHashSet();
143+
144+
foreach (DomainUser userToRemoveFromGroup in group.Users.Where(user => rightUserIds.Contains(user.Id)))
145+
{
146+
var message = OutboxMessage.CreateFromContent(new UserRemovedFromGroupContent
147+
{
148+
UserId = userToRemoveFromGroup.Id,
149+
GroupId = group.Id
150+
});
151+
152+
_pendingMessages.Add(message);
153+
}
154+
}
155+
156+
return Task.CompletedTask;
157+
}
158+
159+
public override async Task OnWritingAsync(DomainGroup group, OperationKind operationKind, CancellationToken cancellationToken)
160+
{
161+
if (operationKind == OperationKind.CreateResource)
162+
{
163+
var message = OutboxMessage.CreateFromContent(new GroupCreatedContent
164+
{
165+
GroupId = group.Id,
166+
GroupName = group.Name
167+
});
168+
169+
await _dbContext.OutboxMessages.AddAsync(message, cancellationToken);
170+
}
171+
else if (operationKind == OperationKind.UpdateResource)
172+
{
173+
if (_beforeGroupName != group.Name)
174+
{
175+
var message = OutboxMessage.CreateFromContent(new GroupRenamedContent
176+
{
177+
GroupId = group.Id,
178+
BeforeGroupName = _beforeGroupName,
179+
AfterGroupName = group.Name
180+
});
181+
182+
await _dbContext.OutboxMessages.AddAsync(message, cancellationToken);
183+
}
184+
}
185+
else if (operationKind == OperationKind.DeleteResource)
186+
{
187+
DomainGroup groupToDelete = await _dbContext.Groups.Include(domainGroup => domainGroup.Users)
188+
.FirstOrDefaultAsync(domainGroup => domainGroup.Id == group.Id, cancellationToken);
189+
190+
if (groupToDelete != null)
191+
{
192+
foreach (DomainUser user in groupToDelete.Users)
193+
{
194+
var removeMessage = OutboxMessage.CreateFromContent(new UserRemovedFromGroupContent
195+
{
196+
UserId = user.Id,
197+
GroupId = group.Id
198+
});
199+
200+
await _dbContext.OutboxMessages.AddAsync(removeMessage, cancellationToken);
201+
}
202+
}
203+
204+
var deleteMessage = OutboxMessage.CreateFromContent(new GroupDeletedContent
205+
{
206+
GroupId = group.Id
207+
});
208+
209+
await _dbContext.OutboxMessages.AddAsync(deleteMessage, cancellationToken);
210+
}
211+
212+
foreach (OutboxMessage nextMessage in _pendingMessages)
213+
{
214+
await _dbContext.OutboxMessages.AddAsync(nextMessage, cancellationToken);
215+
}
216+
}
217+
}
218+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using System;
2+
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Controllers;
4+
using JsonApiDotNetCore.Services;
5+
using Microsoft.Extensions.Logging;
6+
7+
namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Writing.TransactionalOutboxPattern
8+
{
9+
public sealed class DomainGroupsController : JsonApiController<DomainGroup, Guid>
10+
{
11+
public DomainGroupsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<DomainGroup, Guid> resourceService)
12+
: base(options, loggerFactory, resourceService)
13+
{
14+
}
15+
}
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using System;
2+
using System.ComponentModel.DataAnnotations;
3+
using JetBrains.Annotations;
4+
using JsonApiDotNetCore.Resources;
5+
using JsonApiDotNetCore.Resources.Annotations;
6+
7+
namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Writing.TransactionalOutboxPattern
8+
{
9+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
10+
public sealed class DomainUser : Identifiable<Guid>
11+
{
12+
[Attr]
13+
[Required]
14+
public string LoginName { get; set; }
15+
16+
[Attr]
17+
public string DisplayName { get; set; }
18+
19+
[HasOne]
20+
public DomainGroup Group { get; set; }
21+
}
22+
}

0 commit comments

Comments
 (0)