Skip to content

Commit b83bdcd

Browse files
Added multi-tenancy support with Finbuckled as the initial implementation. (#158)
Co-authored-by: jasonmwebb-lv <jason.webb@leadventure.com>
1 parent c91588b commit b83bdcd

53 files changed

Lines changed: 2777 additions & 146 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Src/RCommon.Dapper/Crud/DapperRepository.cs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using System.ComponentModel;
1313
using System.Data.Common;
1414
using RCommon.Entities;
15+
using RCommon.Security.Claims;
1516
using System.Threading;
1617
using Microsoft.Extensions.Options;
1718
using Dommel;
@@ -45,8 +46,9 @@ public class DapperRepository<TEntity> : SqlRepositoryBase<TEntity>
4546
/// <param name="defaultDataStoreOptions">Options specifying which data store to use when none is explicitly set.</param>
4647
public DapperRepository(IDataStoreFactory dataStoreFactory,
4748
ILoggerFactory logger, IEntityEventTracker eventTracker,
48-
IOptions<DefaultDataStoreOptions> defaultDataStoreOptions)
49-
: base(dataStoreFactory, logger, eventTracker, defaultDataStoreOptions)
49+
IOptions<DefaultDataStoreOptions> defaultDataStoreOptions,
50+
ITenantIdAccessor tenantIdAccessor)
51+
: base(dataStoreFactory, logger, eventTracker, defaultDataStoreOptions, tenantIdAccessor)
5052
{
5153
Logger = logger.CreateLogger(GetType().Name);
5254
}
@@ -64,6 +66,7 @@ public override async Task AddAsync(TEntity entity, CancellationToken token = de
6466
await db.OpenAsync();
6567
}
6668
EventTracker.AddEntity(entity);
69+
MultiTenantHelper.SetTenantIdIfApplicable(entity, _tenantIdAccessor.GetTenantId());
6770
await db.InsertAsync(entity, cancellationToken: token);
6871

6972
}
@@ -368,6 +371,7 @@ public override async Task<ICollection<TEntity>> FindAsync(Expression<Func<TEnti
368371
}
369372

370373
var filteredExpression = SoftDeleteHelper.CombineWithNotDeletedFilter<TEntity>(expression);
374+
filteredExpression = MultiTenantHelper.CombineWithTenantFilter(filteredExpression, _tenantIdAccessor.GetTenantId());
371375
var results = await db.SelectAsync(filteredExpression, cancellationToken: token);
372376
return results.ToList();
373377
}
@@ -406,6 +410,15 @@ public override async Task<TEntity> FindAsync(object primaryKey, CancellationTok
406410
return default!;
407411
}
408412

413+
// Post-fetch tenant check: if the entity belongs to a different tenant, treat it as not found
414+
var currentTenantId = _tenantIdAccessor.GetTenantId();
415+
if (result != null && MultiTenantHelper.IsMultiTenant<TEntity>()
416+
&& !string.IsNullOrEmpty(currentTenantId)
417+
&& ((IMultiTenant)result).TenantId != currentTenantId)
418+
{
419+
return default!;
420+
}
421+
409422
return result!;
410423
}
411424
catch (ApplicationException exception)
@@ -436,6 +449,7 @@ public override async Task<long> GetCountAsync(ISpecification<TEntity> selectSpe
436449
}
437450

438451
var filteredPredicate = SoftDeleteHelper.CombineWithNotDeletedFilter<TEntity>(selectSpec.Predicate);
452+
filteredPredicate = MultiTenantHelper.CombineWithTenantFilter(filteredPredicate, _tenantIdAccessor.GetTenantId());
439453
var results = await db.CountAsync(filteredPredicate);
440454
return results;
441455
}
@@ -467,6 +481,7 @@ public override async Task<long> GetCountAsync(Expression<Func<TEntity, bool>> e
467481
}
468482

469483
var filteredExpression = SoftDeleteHelper.CombineWithNotDeletedFilter<TEntity>(expression);
484+
filteredExpression = MultiTenantHelper.CombineWithTenantFilter(filteredExpression, _tenantIdAccessor.GetTenantId());
470485
var results = await db.CountAsync(filteredExpression);
471486
return results;
472487
}
@@ -526,6 +541,7 @@ public override async Task<bool> AnyAsync(Expression<Func<TEntity, bool>> expres
526541
}
527542

528543
var filteredExpression = SoftDeleteHelper.CombineWithNotDeletedFilter<TEntity>(expression);
544+
filteredExpression = MultiTenantHelper.CombineWithTenantFilter(filteredExpression, _tenantIdAccessor.GetTenantId());
529545
var results = await db.AnyAsync(filteredExpression);
530546
return results;
531547
}
@@ -571,6 +587,7 @@ public override async Task AddRangeAsync(IEnumerable<TEntity> entities, Cancella
571587
foreach (var entity in entities)
572588
{
573589
EventTracker.AddEntity(entity);
590+
MultiTenantHelper.SetTenantIdIfApplicable(entity, _tenantIdAccessor.GetTenantId());
574591
await db.InsertAsync(entity, cancellationToken: token);
575592
}
576593
}

Src/RCommon.Dapper/DapperPersistenceBuilder.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Microsoft.Extensions.DependencyInjection;
1010
using RCommon.Persistence.Dapper.Crud;
1111
using RCommon.Persistence.Crud;
12+
using RCommon.Security.Claims;
1213
using Microsoft.Extensions.DependencyInjection.Extensions;
1314

1415
namespace RCommon
@@ -38,6 +39,9 @@ public DapperPersistenceBuilder(IServiceCollection services)
3839
{
3940
_services = services ?? throw new ArgumentNullException(nameof(services));
4041

42+
// Default tenant accessor (no-op); overridden when multitenancy is configured
43+
services.TryAddTransient<ITenantIdAccessor, NullTenantIdAccessor>();
44+
4145
// Dapper Repository
4246
services.AddTransient(typeof(ISqlMapperRepository<>), typeof(DapperRepository<>));
4347
services.AddTransient(typeof(IWriteOnlyRepository<>), typeof(DapperRepository<>));

Src/RCommon.Dapper/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ Dapper implementation of the RCommon persistence abstractions. Provides a lightw
1313
- Named data store support for multi-database scenarios through `IDataStoreFactory` and `RDbConnection`
1414
- Fluent DI configuration to register database connections as named data stores
1515
- Domain event tracking integrated into add, update, and delete operations
16+
- **Soft delete** -- entities implementing `ISoftDelete` are automatically filtered on reads and logically deleted on writes
17+
- **Multitenancy** -- entities implementing `IMultiTenant` are automatically filtered by tenant on reads and stamped with `TenantId` on writes
1618
- Targets .NET 8, .NET 9, and .NET 10
1719

1820
## Installation
@@ -73,6 +75,38 @@ public class ProductService
7375
}
7476
```
7577

78+
### Soft Delete and Multitenancy
79+
80+
`DapperRepository<TEntity>` automatically supports soft delete and multitenancy when your entities implement the opt-in interfaces:
81+
82+
```csharp
83+
using RCommon.Entities;
84+
85+
public class Product : BusinessEntity<int>, ISoftDelete, IMultiTenant
86+
{
87+
public string Name { get; set; }
88+
public bool IsDeleted { get; set; }
89+
public string? TenantId { get; set; }
90+
}
91+
```
92+
93+
Reads automatically exclude soft-deleted records and scope to the current tenant:
94+
95+
```csharp
96+
// Both filters applied transparently
97+
var products = await _productRepo.FindAsync(p => p.IsActive);
98+
```
99+
100+
Writes automatically stamp the tenant and support logical deletion:
101+
102+
```csharp
103+
// TenantId stamped automatically from ITenantIdAccessor
104+
await _productRepo.AddAsync(new Product { Name = "Widget" });
105+
106+
// Soft delete — sets IsDeleted = true, performs UPDATE via Dapper
107+
await _productRepo.DeleteAsync(product, isSoftDelete: true);
108+
```
109+
76110
## Key Types
77111

78112
| Type | Description |

Src/RCommon.EfCore/Crud/EFCoreRepository.cs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Microsoft.Extensions.Options;
66
using RCommon;
77
using RCommon.Entities;
8+
using RCommon.Security.Claims;
89
using RCommon.Collections;
910
using RCommon.Linq;
1011
using System;
@@ -52,8 +53,9 @@ public class EFCoreRepository<TEntity> : GraphRepositoryBase<TEntity>
5253
/// <exception cref="ArgumentNullException">Thrown when any parameter is <c>null</c>.</exception>
5354
public EFCoreRepository(IDataStoreFactory dataStoreFactory,
5455
ILoggerFactory logger, IEntityEventTracker eventTracker,
55-
IOptions<DefaultDataStoreOptions> defaultDataStoreOptions)
56-
: base(dataStoreFactory, eventTracker, defaultDataStoreOptions)
56+
IOptions<DefaultDataStoreOptions> defaultDataStoreOptions,
57+
ITenantIdAccessor tenantIdAccessor)
58+
: base(dataStoreFactory, eventTracker, defaultDataStoreOptions, tenantIdAccessor)
5759
{
5860
if (logger is null)
5961
{
@@ -159,6 +161,7 @@ protected override IQueryable<TEntity> RepositoryQuery
159161
public override async Task AddAsync(TEntity entity, CancellationToken token = default)
160162
{
161163
EventTracker.AddEntity(entity);
164+
MultiTenantHelper.SetTenantIdIfApplicable(entity, _tenantIdAccessor.GetTenantId());
162165
await ObjectSet.AddAsync(entity, token);
163166
await SaveAsync(token);
164167
}
@@ -346,6 +349,15 @@ public override async Task<TEntity> FindAsync(object primaryKey, CancellationTok
346349
return default!;
347350
}
348351

352+
// Post-fetch tenant check: if the entity belongs to a different tenant, treat it as not found
353+
var currentTenantId = _tenantIdAccessor.GetTenantId();
354+
if (entity != null && MultiTenantHelper.IsMultiTenant<TEntity>()
355+
&& !string.IsNullOrEmpty(currentTenantId)
356+
&& ((IMultiTenant)entity).TenantId != currentTenantId)
357+
{
358+
return default!;
359+
}
360+
349361
return entity!;
350362
}
351363

@@ -498,10 +510,11 @@ public override async Task AddRangeAsync(IEnumerable<TEntity> entities, Cancella
498510
{
499511
if (entities == null) throw new ArgumentNullException(nameof(entities));
500512

501-
// track each entity prior to adding
513+
// track each entity and stamp tenant prior to adding
502514
foreach (var entity in entities)
503515
{
504516
EventTracker.AddEntity(entity);
517+
MultiTenantHelper.SetTenantIdIfApplicable(entity, _tenantIdAccessor.GetTenantId());
505518
}
506519

507520
await ObjectSet.AddRangeAsync(entities, token);

Src/RCommon.EfCore/EFCorePerisistenceBuilder.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using RCommon.Persistence.Crud;
1212
using RCommon.Persistence.EFCore;
1313
using RCommon.Persistence.EFCore.Crud;
14+
using RCommon.Security.Claims;
1415

1516
namespace RCommon
1617
{
@@ -37,6 +38,9 @@ public EFCorePerisistenceBuilder(IServiceCollection services)
3738
{
3839
_services = services ?? throw new ArgumentNullException(nameof(services));
3940

41+
// Default tenant accessor (no-op); overridden when multitenancy is configured
42+
services.TryAddTransient<ITenantIdAccessor, NullTenantIdAccessor>();
43+
4044
// EF Core Repository
4145
services.AddTransient(typeof(IReadOnlyRepository<>), typeof(EFCoreRepository<>));
4246
services.AddTransient(typeof(IWriteOnlyRepository<>), typeof(EFCoreRepository<>));

Src/RCommon.EfCore/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ Entity Framework Core implementation of the RCommon persistence abstractions. Pr
1414
- `RCommonDbContext` base class implementing `IDataStore` for seamless factory resolution
1515
- Fluent DI configuration to register DbContexts as named data stores
1616
- Automatic entity event tracking for domain event dispatching on add, update, and delete
17+
- **Soft delete** -- entities implementing `ISoftDelete` are automatically filtered on reads and logically deleted on writes
18+
- **Multitenancy** -- entities implementing `IMultiTenant` are automatically filtered by tenant on reads and stamped with `TenantId` on writes
1719
- Targets .NET 8, .NET 9, and .NET 10
1820

1921
## Installation
@@ -70,6 +72,38 @@ public class OrderService
7072
}
7173
```
7274

75+
### Soft Delete and Multitenancy
76+
77+
`EFCoreRepository<TEntity>` automatically supports soft delete and multitenancy when your entities implement the opt-in interfaces:
78+
79+
```csharp
80+
using RCommon.Entities;
81+
82+
public class Customer : BusinessEntity<int>, ISoftDelete, IMultiTenant
83+
{
84+
public string Name { get; set; }
85+
public bool IsDeleted { get; set; }
86+
public string? TenantId { get; set; }
87+
}
88+
```
89+
90+
Reads automatically exclude soft-deleted records and scope to the current tenant:
91+
92+
```csharp
93+
// Both filters applied transparently — only active customers for the current tenant
94+
var customers = await _customerRepo.FindAsync(c => c.Name.StartsWith("A"));
95+
```
96+
97+
Writes automatically stamp the tenant and support logical deletion:
98+
99+
```csharp
100+
// TenantId stamped automatically from ITenantIdAccessor
101+
await _customerRepo.AddAsync(new Customer { Name = "Acme" });
102+
103+
// Soft delete — sets IsDeleted = true, performs UPDATE via EF Core
104+
await _customerRepo.DeleteAsync(customer, isSoftDelete: true);
105+
```
106+
73107
## Key Types
74108

75109
| Type | Description |
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
namespace RCommon.Entities
2+
{
3+
/// <summary>
4+
/// Marks an entity as belonging to a specific tenant. When multitenancy is configured,
5+
/// the repository will automatically set <see cref="TenantId"/> on add operations and
6+
/// filter read operations to only return entities matching the current tenant.
7+
/// </summary>
8+
/// <remarks>
9+
/// <para>
10+
/// This is an opt-in capability interface. Entities that do not implement this interface
11+
/// are not tenant-scoped and will not be filtered by tenant. If no <see cref="RCommon.Persistence.Crud.ITenantIdAccessor"/>
12+
/// is configured (or the accessor returns <c>null</c>), tenant filtering is bypassed entirely,
13+
/// allowing the application to operate without multitenancy.
14+
/// </para>
15+
/// <para>
16+
/// <strong>Usage:</strong> To enable multitenancy for an entity, implement this interface and
17+
/// ensure the underlying data store has a corresponding <c>TenantId</c> column (string).
18+
/// Configure multitenancy during bootstrapping using <c>.WithMultiTenancy&lt;T&gt;()</c>.
19+
/// </para>
20+
/// <example>
21+
/// <code>
22+
/// public class Customer : BusinessEntity&lt;int&gt;, IMultiTenant
23+
/// {
24+
/// public string Name { get; set; }
25+
/// public string? TenantId { get; set; }
26+
/// }
27+
/// </code>
28+
/// </example>
29+
/// </remarks>
30+
public interface IMultiTenant
31+
{
32+
/// <summary>
33+
/// Gets or sets the identifier of the tenant that owns this entity.
34+
/// When <c>null</c>, the entity is not associated with any tenant.
35+
/// </summary>
36+
string? TenantId { get; set; }
37+
}
38+
}

Src/RCommon.Entities/README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ Domain entity base classes for the RCommon framework, providing a strongly-typed
1010
- `AuditedEntity` base classes that track `CreatedBy`, `DateCreated`, `LastModifiedBy`, and `DateLastModified` with flexible user types
1111
- `ITrackedEntity` interface for opting entities into event tracking
1212
- `IEntityEventTracker` and `InMemoryEntityEventTracker` for collecting entity events across object graphs and routing them through `IEventRouter`
13+
- **Soft delete** -- `ISoftDelete` opt-in interface for logical deletion (`IsDeleted` flag) instead of physical removal
14+
- **Multitenancy** -- `IMultiTenant` opt-in interface for tenant-scoped entities (`TenantId` property)
1315
- `EntityNotFoundException` for consistent "entity not found" error handling with type and ID context
1416

1517
## Installation
@@ -63,6 +65,53 @@ public class OrderService
6365
}
6466
```
6567

68+
### Soft Delete
69+
70+
Implement `ISoftDelete` to opt an entity into logical deletion. Repositories will set `IsDeleted = true` and perform an UPDATE instead of a physical DELETE:
71+
72+
```csharp
73+
using RCommon.Entities;
74+
75+
public class Customer : BusinessEntity<int>, ISoftDelete
76+
{
77+
public string Name { get; set; }
78+
public bool IsDeleted { get; set; }
79+
}
80+
81+
// Soft delete — sets IsDeleted = true, performs UPDATE
82+
await repository.DeleteAsync(customer, isSoftDelete: true);
83+
84+
// Physical delete — removes the record entirely
85+
await repository.DeleteAsync(customer, isSoftDelete: false);
86+
```
87+
88+
Entities implementing `ISoftDelete` are automatically filtered on read operations -- soft-deleted records are excluded from query results by default.
89+
90+
### Multitenancy
91+
92+
Implement `IMultiTenant` to scope an entity to a specific tenant. Repositories will automatically stamp the `TenantId` on add operations and filter reads to only return records for the current tenant:
93+
94+
```csharp
95+
using RCommon.Entities;
96+
97+
public class Product : BusinessEntity<int>, IMultiTenant
98+
{
99+
public string Name { get; set; }
100+
public string? TenantId { get; set; }
101+
}
102+
```
103+
104+
When both interfaces are combined, the entity supports soft delete and tenant isolation:
105+
106+
```csharp
107+
public class Invoice : BusinessEntity<Guid>, ISoftDelete, IMultiTenant
108+
{
109+
public decimal Amount { get; set; }
110+
public bool IsDeleted { get; set; }
111+
public string? TenantId { get; set; }
112+
}
113+
```
114+
66115
## Key Types
67116

68117
| Type | Description |
@@ -76,6 +125,8 @@ public class OrderService
76125
| `ITrackedEntity` | Marks an entity as eligible for event tracking via `AllowEventTracking` |
77126
| `IEntityEventTracker` | Collects tracked entities and emits their transactional events |
78127
| `InMemoryEntityEventTracker` | In-memory implementation that traverses entity object graphs and routes events |
128+
| `ISoftDelete` | Opt-in interface for soft delete; adds `IsDeleted` property for logical deletion |
129+
| `IMultiTenant` | Opt-in interface for multitenancy; adds `TenantId` property for tenant scoping |
79130
| `EntityNotFoundException` | Exception for when an expected entity does not exist, with type and ID context |
80131

81132
## Documentation

0 commit comments

Comments
 (0)