Skip to content

Commit 40ce13e

Browse files
committed
Feat: UnitOfWork
1 parent b54451f commit 40ce13e

File tree

17 files changed

+381
-6
lines changed

17 files changed

+381
-6
lines changed

.editorconfig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ indent_size = 4
99
# Microsoft .NET properties
1010
csharp_new_line_before_members_in_object_initializers = false
1111
csharp_new_line_before_open_brace = none
12-
csharp_preferred_modifier_order = public, internal, protected, private, static, async, virtual, file, new, sealed, override, required, abstract, extern, volatile, unsafe, readonly:suggestion
12+
csharp_preferred_modifier_order = public, internal, protected, private, static, virtual, async, file, new, sealed, override, required, abstract, extern, volatile, unsafe, readonly:suggestion
1313
csharp_style_prefer_utf8_string_literals = true:suggestion
1414
csharp_style_var_elsewhere = false:none
1515
csharp_style_var_for_built_in_types = false:suggestion

CodeOfChaos.Types.sln

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.CodeOfChaos.Types.Dat
2222
EndProject
2323
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Benchmarks.CodeOfChaos.Types.TypedValueStore", "tests\Benchmarks.CodeOfChaos.Types.TypedValueStore\Benchmarks.CodeOfChaos.Types.TypedValueStore.csproj", "{46E31210-89A5-4E2B-8C89-4757ED54E869}"
2424
EndProject
25+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeOfChaos.Types.UnitOfWork", "src\CodeOfChaos.Types.UnitOfWork\CodeOfChaos.Types.UnitOfWork.csproj", "{938BE8FB-AAB5-4D15-82FC-DFABC83DB2F1}"
26+
EndProject
27+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.CodeOfChaos.Types.UnitOfWork", "tests\Tests.CodeOfChaos.Types.UnitOfWork\Tests.CodeOfChaos.Types.UnitOfWork.csproj", "{58C5DC7B-083F-42B0-AE5E-A7F662C0EF39}"
28+
EndProject
2529
Global
2630
GlobalSection(SolutionConfigurationPlatforms) = preSolution
2731
Debug|Any CPU = Debug|Any CPU
@@ -60,6 +64,14 @@ Global
6064
{46E31210-89A5-4E2B-8C89-4757ED54E869}.Debug|Any CPU.Build.0 = Debug|Any CPU
6165
{46E31210-89A5-4E2B-8C89-4757ED54E869}.Release|Any CPU.ActiveCfg = Release|Any CPU
6266
{46E31210-89A5-4E2B-8C89-4757ED54E869}.Release|Any CPU.Build.0 = Release|Any CPU
67+
{938BE8FB-AAB5-4D15-82FC-DFABC83DB2F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
68+
{938BE8FB-AAB5-4D15-82FC-DFABC83DB2F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
69+
{938BE8FB-AAB5-4D15-82FC-DFABC83DB2F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
70+
{938BE8FB-AAB5-4D15-82FC-DFABC83DB2F1}.Release|Any CPU.Build.0 = Release|Any CPU
71+
{58C5DC7B-083F-42B0-AE5E-A7F662C0EF39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
72+
{58C5DC7B-083F-42B0-AE5E-A7F662C0EF39}.Debug|Any CPU.Build.0 = Debug|Any CPU
73+
{58C5DC7B-083F-42B0-AE5E-A7F662C0EF39}.Release|Any CPU.ActiveCfg = Release|Any CPU
74+
{58C5DC7B-083F-42B0-AE5E-A7F662C0EF39}.Release|Any CPU.Build.0 = Release|Any CPU
6375
EndGlobalSection
6476
GlobalSection(NestedProjects) = preSolution
6577
{26284571-0E09-4BAF-8C2B-DF87DCC1BA0B} = {8DD280D4-1E14-4D5E-AFE6-58DD8F079DCC}
@@ -70,5 +82,7 @@ Global
7082
{5087A810-E729-4D6F-8CC1-4969D6B1F282} = {197E72AD-DEAB-4350-AFC3-A3BB38720BF5}
7183
{228BA72E-139E-4C32-AC14-1B415921CE18} = {8DD280D4-1E14-4D5E-AFE6-58DD8F079DCC}
7284
{46E31210-89A5-4E2B-8C89-4757ED54E869} = {8DD280D4-1E14-4D5E-AFE6-58DD8F079DCC}
85+
{938BE8FB-AAB5-4D15-82FC-DFABC83DB2F1} = {197E72AD-DEAB-4350-AFC3-A3BB38720BF5}
86+
{58C5DC7B-083F-42B0-AE5E-A7F662C0EF39} = {8DD280D4-1E14-4D5E-AFE6-58DD8F079DCC}
7387
EndGlobalSection
7488
EndGlobal
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net9.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<PackageReference Include="CodeOfChaos.Extensions.DependencyInjection" Version="0.32.0" />
11+
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.1" />
12+
</ItemGroup>
13+
14+
<ItemGroup>
15+
<ProjectReference Include="..\CodeOfChaos.Types\CodeOfChaos.Types.csproj" />
16+
</ItemGroup>
17+
18+
</Project>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
2+
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=contracts/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// ---------------------------------------------------------------------------------------------------------------------
2+
// Imports
3+
// ---------------------------------------------------------------------------------------------------------------------
4+
namespace CodeOfChaos.Types.UnitOfWork;
5+
// ---------------------------------------------------------------------------------------------------------------------
6+
// Code
7+
// ---------------------------------------------------------------------------------------------------------------------
8+
public interface IRepository {
9+
void Attach(IUnitOfWork unitOfWork);
10+
void Detach(IUnitOfWork unitOfWork);
11+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// ---------------------------------------------------------------------------------------------------------------------
2+
// Imports
3+
// ---------------------------------------------------------------------------------------------------------------------
4+
using Microsoft.EntityFrameworkCore;
5+
6+
namespace CodeOfChaos.Types.UnitOfWork;
7+
// ---------------------------------------------------------------------------------------------------------------------
8+
// Code
9+
// ---------------------------------------------------------------------------------------------------------------------
10+
public interface IUnitOfWork : IAsyncDisposable {
11+
// -----------------------------------------------------------------------------------------------------------------
12+
// Methods
13+
// ----------------------------------------------------------------------------------------------------------------
14+
ValueTask SaveChangesAsync(CancellationToken ct = default);
15+
ValueTask<bool> TryCommitTransactionAsync(CancellationToken ct = default);
16+
ValueTask<bool> TryCreateTransactionAsync(CancellationToken ct = default);
17+
ValueTask<bool> TryRollbackTransactionAsync(CancellationToken ct = default);
18+
ValueTask<bool> TryRollbackToSavepointAsync(Guid id, CancellationToken ct = default);
19+
ValueTask<bool> TryCreateSavepointAsync(Guid id, CancellationToken ct = default);
20+
21+
ValueTask<TDbContext> GetDbContextAsync<TDbContext>(CancellationToken ct = default) where TDbContext : DbContext;
22+
23+
TRepo GetRepository<TRepo>() where TRepo : class, IRepository;
24+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// ---------------------------------------------------------------------------------------------------------------------
2+
// Imports
3+
// ---------------------------------------------------------------------------------------------------------------------
4+
using CodeOfChaos.Extensions.DependencyInjection;
5+
6+
namespace CodeOfChaos.Types.UnitOfWork;
7+
// ---------------------------------------------------------------------------------------------------------------------
8+
// Code
9+
// ---------------------------------------------------------------------------------------------------------------------
10+
public interface IUnitOfWorkFactory : IFactoryService<IUnitOfWork> {
11+
ValueTask<IUnitOfWork> CreateWithTransactionAsync(CancellationToken ct = default);
12+
ValueTask<IUnitOfWork?> TryCreateWithTransactionAsync(CancellationToken ct = default);
13+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// ---------------------------------------------------------------------------------------------------------------------
2+
// Imports
3+
// ---------------------------------------------------------------------------------------------------------------------
4+
using Microsoft.EntityFrameworkCore;
5+
using Microsoft.Extensions.DependencyInjection;
6+
7+
namespace CodeOfChaos.Types.UnitOfWork;
8+
9+
// ---------------------------------------------------------------------------------------------------------------------
10+
// Code
11+
// ---------------------------------------------------------------------------------------------------------------------
12+
public static class ServiceCollectionExtenions {
13+
public static IServiceCollection AddUnitOfWork<TDbContext>(this IServiceCollection services) where TDbContext : DbContext {
14+
services.AddScoped<IUnitOfWorkFactory, UnitOfWorkFactory<TDbContext>>();
15+
services.AddScoped<IUnitOfWork>(static sp => sp.GetRequiredService<IUnitOfWorkFactory>().Create());
16+
17+
return services;
18+
}
19+
20+
public static IServiceCollection AddUnitOfWork<TDbContext>(this IServiceCollection services, string key) where TDbContext : DbContext {
21+
services.AddKeyedScoped<IUnitOfWorkFactory, UnitOfWorkFactory<TDbContext>>(key);
22+
services.AddKeyedScoped<IUnitOfWork>(key, static (sp, k) => sp.GetRequiredKeyedService<IUnitOfWorkFactory>(k).Create());
23+
24+
return services;
25+
}
26+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// ---------------------------------------------------------------------------------------------------------------------
2+
// Imports
3+
// ---------------------------------------------------------------------------------------------------------------------
4+
using Microsoft.EntityFrameworkCore;
5+
using Microsoft.EntityFrameworkCore.Storage;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using System.Collections.Concurrent;
8+
9+
namespace CodeOfChaos.Types.UnitOfWork;
10+
11+
// ---------------------------------------------------------------------------------------------------------------------
12+
// Code
13+
// ---------------------------------------------------------------------------------------------------------------------
14+
public class UnitOfWork<TDbContext>(IDbContextFactory<TDbContext> dbContextFactory, IServiceScope serviceScope) : IUnitOfWork where TDbContext : DbContext{
15+
private readonly AsyncLazy<TDbContext> _db = new(async ct => await dbContextFactory.CreateDbContextAsync(ct));
16+
private IDbContextTransaction? _transaction;
17+
private ConcurrentDictionary<Type, IRepository> AttachedRepositories { get; } = [];
18+
19+
// -----------------------------------------------------------------------------------------------------------------
20+
// Methods
21+
// -----------------------------------------------------------------------------------------------------------------
22+
public virtual async ValueTask SaveChangesAsync(CancellationToken ct = default) {
23+
DbContext dbContext = await _db.GetValueAsync(ct);
24+
await dbContext.SaveChangesAsync(ct);
25+
}
26+
27+
public virtual async ValueTask<bool> TryCommitTransactionAsync(CancellationToken ct = default) {
28+
if (_transaction == null) return false;
29+
30+
await _transaction.CommitAsync(ct);
31+
_transaction.Dispose();
32+
_transaction = null;
33+
34+
return true;
35+
}
36+
37+
public virtual async ValueTask<bool> TryCreateTransactionAsync(CancellationToken ct = default) {
38+
if (_transaction != null) return false;
39+
40+
TDbContext dbContext = await _db.GetValueAsync(ct);
41+
if (dbContext.Database.CurrentTransaction != null) return false;
42+
43+
_transaction = await dbContext.Database.BeginTransactionAsync(ct);
44+
45+
return true;
46+
}
47+
48+
public virtual async ValueTask<bool> TryRollbackTransactionAsync(CancellationToken ct = default) {
49+
if (_transaction == null) return false;
50+
51+
await _transaction.RollbackAsync(ct);
52+
_transaction.Dispose();
53+
_transaction = null;
54+
55+
return true;
56+
}
57+
58+
public virtual async ValueTask<bool> TryRollbackToSavepointAsync(Guid id, CancellationToken ct = default) {
59+
if (_transaction == null) return false;
60+
if (!_transaction.SupportsSavepoints) return false;
61+
62+
await _transaction.RollbackToSavepointAsync(id.ToString("N"), ct);
63+
64+
return true;
65+
}
66+
67+
public virtual async ValueTask<bool> TryCreateSavepointAsync(Guid id, CancellationToken ct = default) {
68+
if (_transaction == null) return false;
69+
if (!_transaction.SupportsSavepoints) return false;
70+
71+
await _transaction.CreateSavepointAsync(id.ToString("N"), ct);
72+
73+
return true;
74+
}
75+
76+
public virtual async ValueTask<T> GetDbContextAsync<T>(CancellationToken ct = default) where T : DbContext {
77+
if (typeof(T) != typeof(TDbContext)) throw new NotSupportedException($"DbContext type '{typeof(T)}' is not supported by this UnitOfWork.");
78+
79+
TDbContext dbContext = await _db.GetValueAsync(ct);
80+
return dbContext as T ?? throw new InvalidCastException($"Cannot cast DbContext of type '{dbContext.GetType()}' to '{typeof(T)}'");
81+
}
82+
83+
public virtual TRepo GetRepository<TRepo>() where TRepo : class, IRepository {
84+
if (AttachedRepositories.TryGetValue(typeof(TRepo), out IRepository? cachedRepo) && cachedRepo is TRepo castedCachedRepo) return castedCachedRepo;
85+
86+
// Cache miss so we create a new instance
87+
var repo = serviceScope.ServiceProvider.GetRequiredService<TRepo>();
88+
89+
repo.Attach(this);
90+
AttachedRepositories.AddOrUpdate(typeof(TRepo), repo);
91+
return repo;
92+
}
93+
94+
public virtual async ValueTask DisposeAsync() {
95+
if (_transaction != null) {
96+
await TryRollbackTransactionAsync();
97+
await _transaction.DisposeAsync();
98+
_transaction = null;
99+
}
100+
101+
if (!AttachedRepositories.IsEmpty) {
102+
// First detach all references to this unit of work
103+
foreach ((_, IRepository repo) in AttachedRepositories) {
104+
repo.Detach(this);
105+
}
106+
107+
// Then clear our own reference to them
108+
AttachedRepositories.Clear();
109+
}
110+
111+
serviceScope.Dispose();
112+
113+
await _db.DisposeAsync();
114+
115+
GC.SuppressFinalize(this);
116+
}
117+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// ---------------------------------------------------------------------------------------------------------------------
2+
// Imports
3+
// ---------------------------------------------------------------------------------------------------------------------
4+
using CodeOfChaos.Extensions.DependencyInjection;
5+
using Microsoft.EntityFrameworkCore;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using Microsoft.Extensions.Logging;
8+
9+
namespace CodeOfChaos.Types.UnitOfWork;
10+
11+
// ---------------------------------------------------------------------------------------------------------------------
12+
// Code
13+
// ---------------------------------------------------------------------------------------------------------------------
14+
[InjectableService<IUnitOfWorkFactory>(ServiceLifetime.Scoped)]
15+
public class UnitOfWorkFactory<TDbContext>(IDbContextFactory<TDbContext> dbContextFactory, IServiceProvider provider, ILogger<UnitOfWorkFactory<TDbContext>> logger) : IUnitOfWorkFactory where TDbContext : DbContext{
16+
public IUnitOfWork Create() {
17+
// Each unit of work should have their own scope which they pull their repositories from
18+
// This, if the factory is used correctly, should enforce correct usage and limit dbcontext concurrency issues.
19+
IServiceScope scope = provider.CreateScope();
20+
21+
// Because our factory doesn't create the actual dbcontext, yet we are safe, and we can just inject it downwards.
22+
return new UnitOfWork<TDbContext>(dbContextFactory, scope);
23+
}
24+
25+
public async ValueTask<IUnitOfWork> CreateWithTransactionAsync(CancellationToken ct = default) {
26+
IUnitOfWork unitOfWork = Create();
27+
28+
// ReSharper disable once InvertIf
29+
if (!await unitOfWork.TryCreateTransactionAsync(ct)) {
30+
logger.LogError("Failed to create transaction for new unit of work.");
31+
throw new Exception("Failed to create transaction");
32+
}
33+
return unitOfWork;
34+
}
35+
36+
public async ValueTask<IUnitOfWork?> TryCreateWithTransactionAsync(CancellationToken ct = default) {
37+
IUnitOfWork unitOfWork = Create();
38+
39+
// ReSharper disable once InvertIf
40+
if (!await unitOfWork.TryCreateTransactionAsync(ct)) {
41+
logger.LogError("Failed to create transaction for new unit of work.");
42+
return null;
43+
}
44+
45+
return unitOfWork;
46+
}
47+
}

0 commit comments

Comments
 (0)