diff --git a/CleanArchitecture.Api/CleanArchitecture.Api.csproj b/CleanArchitecture.Api/CleanArchitecture.Api.csproj
index ca184ec..5b5b96b 100644
--- a/CleanArchitecture.Api/CleanArchitecture.Api.csproj
+++ b/CleanArchitecture.Api/CleanArchitecture.Api.csproj
@@ -7,26 +7,28 @@
-
-
-
-
-
+
+
+
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
-
+
+
+
+
+
-
-
-
-
+
+
+
+
diff --git a/CleanArchitecture.Api/Program.cs b/CleanArchitecture.Api/Program.cs
index 332f47a..4036e29 100644
--- a/CleanArchitecture.Api/Program.cs
+++ b/CleanArchitecture.Api/Program.cs
@@ -14,6 +14,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
+using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);
@@ -30,7 +31,8 @@ if (builder.Environment.IsProduction())
{
builder.Services
.AddHealthChecks()
- .AddSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")!);
+ .AddSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")!)
+ .AddRedis(builder.Configuration["RedisHostName"]!, "Redis");
}
builder.Services.AddDbContext(options =>
@@ -59,6 +61,19 @@ builder.Services.AddLogging(x => x.AddSimpleConsole(console =>
console.IncludeScopes = true;
}));
+if (builder.Environment.IsProduction())
+{
+ builder.Services.AddStackExchangeRedisCache(options =>
+ {
+ options.Configuration = builder.Configuration["RedisHostName"];
+ options.InstanceName = "clean-architecture";
+ });
+}
+else
+{
+ builder.Services.AddDistributedMemoryCache();
+}
+
var app = builder.Build();
using (var scope = app.Services.CreateScope())
diff --git a/CleanArchitecture.Api/appsettings.json b/CleanArchitecture.Api/appsettings.json
index b4c963f..6ea892f 100644
--- a/CleanArchitecture.Api/appsettings.json
+++ b/CleanArchitecture.Api/appsettings.json
@@ -13,5 +13,6 @@
"Issuer": "CleanArchitectureServer",
"Audience": "CleanArchitectureClient",
"Secret": "sD3v061gf8BxXgmxcHssasjdlkasjd87439284)@#(*"
- }
+ },
+ "RedisHostName": "redis"
}
diff --git a/CleanArchitecture.Application/Services/TenantService.cs b/CleanArchitecture.Application/Services/TenantService.cs
index 0b77343..709cb4b 100644
--- a/CleanArchitecture.Application/Services/TenantService.cs
+++ b/CleanArchitecture.Application/Services/TenantService.cs
@@ -6,20 +6,26 @@ using CleanArchitecture.Application.Queries.Tenants.GetAll;
using CleanArchitecture.Application.Queries.Tenants.GetTenantById;
using CleanArchitecture.Application.ViewModels;
using CleanArchitecture.Application.ViewModels.Tenants;
+using CleanArchitecture.Domain;
using CleanArchitecture.Domain.Commands.Tenants.CreateTenant;
using CleanArchitecture.Domain.Commands.Tenants.DeleteTenant;
using CleanArchitecture.Domain.Commands.Tenants.UpdateTenant;
+using CleanArchitecture.Domain.Entities;
+using CleanArchitecture.Domain.Extensions;
using CleanArchitecture.Domain.Interfaces;
+using Microsoft.Extensions.Caching.Distributed;
namespace CleanArchitecture.Application.Services;
public sealed class TenantService : ITenantService
{
private readonly IMediatorHandler _bus;
+ private readonly IDistributedCache _distributedCache;
- public TenantService(IMediatorHandler bus)
+ public TenantService(IMediatorHandler bus, IDistributedCache distributedCache)
{
_bus = bus;
+ _distributedCache = distributedCache;
}
public async Task CreateTenantAsync(CreateTenantViewModel tenant)
@@ -47,7 +53,16 @@ public sealed class TenantService : ITenantService
public async Task GetTenantByIdAsync(Guid tenantId, bool deleted)
{
- return await _bus.QueryAsync(new GetTenantByIdQuery(tenantId, deleted));
+ var cachedTenant = await _distributedCache.GetOrCreateJsonAsync(
+ CacheKeyGenerator.GetEntityCacheKey(tenantId),
+ async () => await _bus.QueryAsync(new GetTenantByIdQuery(tenantId, deleted)),
+ new DistributedCacheEntryOptions
+ {
+ SlidingExpiration = TimeSpan.FromDays(3),
+ AbsoluteExpiration = DateTimeOffset.Now.AddDays(30)
+ });
+
+ return cachedTenant;
}
public async Task> GetAllTenantsAsync(PageQuery query, string searchTerm = "")
diff --git a/CleanArchitecture.Application/ViewModels/Tenants/TenantViewModel.cs b/CleanArchitecture.Application/ViewModels/Tenants/TenantViewModel.cs
index af6bb43..23341d0 100644
--- a/CleanArchitecture.Application/ViewModels/Tenants/TenantViewModel.cs
+++ b/CleanArchitecture.Application/ViewModels/Tenants/TenantViewModel.cs
@@ -18,7 +18,7 @@ public sealed class TenantViewModel
{
Id = tenant.Id,
Name = tenant.Name,
- Users = tenant.Users.Select(UserViewModel.FromUser)
+ Users = tenant.Users.Select(UserViewModel.FromUser).ToList()
};
}
}
\ No newline at end of file
diff --git a/CleanArchitecture.Application/viewmodels/Users/UserViewModel.cs b/CleanArchitecture.Application/viewmodels/Users/UserViewModel.cs
index 01ed246..f477101 100644
--- a/CleanArchitecture.Application/viewmodels/Users/UserViewModel.cs
+++ b/CleanArchitecture.Application/viewmodels/Users/UserViewModel.cs
@@ -7,6 +7,7 @@ namespace CleanArchitecture.Application.ViewModels.Users;
public sealed class UserViewModel
{
public Guid Id { get; set; }
+ public Guid TenantId { get; set; }
public string Email { get; set; } = string.Empty;
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
@@ -18,6 +19,7 @@ public sealed class UserViewModel
return new UserViewModel
{
Id = user.Id,
+ TenantId = user.TenantId,
Email = user.Email,
FirstName = user.FirstName,
LastName = user.LastName,
diff --git a/CleanArchitecture.Domain/CacheKeyGenerator.cs b/CleanArchitecture.Domain/CacheKeyGenerator.cs
new file mode 100644
index 0000000..aa99aea
--- /dev/null
+++ b/CleanArchitecture.Domain/CacheKeyGenerator.cs
@@ -0,0 +1,13 @@
+using System;
+using CleanArchitecture.Domain.Entities;
+
+namespace CleanArchitecture.Domain;
+
+public static class CacheKeyGenerator
+{
+ public static string GetEntityCacheKey(TEntity entity) where TEntity : Entity =>
+ $"{typeof(TEntity)}-{entity.Id}";
+
+ public static string GetEntityCacheKey(Guid id) where TEntity : Entity =>
+ $"{typeof(TEntity)}-{id}";
+}
\ No newline at end of file
diff --git a/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj b/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj
index a5877fe..e192c77 100644
--- a/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj
+++ b/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj
@@ -6,11 +6,12 @@
-
-
-
-
-
+
+
+
+
+
+
diff --git a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs
index 8eb5388..39d3c2d 100644
--- a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs
+++ b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs
@@ -100,7 +100,7 @@ public sealed class CreateUserCommandHandler : CommandHandlerBase,
if (await CommitAsync())
{
- await Bus.RaiseEventAsync(new UserCreatedEvent(user.Id));
+ await Bus.RaiseEventAsync(new UserCreatedEvent(user.Id, user.TenantId));
}
}
}
\ No newline at end of file
diff --git a/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandHandler.cs
index e7d69b5..ef174bc 100644
--- a/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandHandler.cs
+++ b/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandHandler.cs
@@ -62,7 +62,7 @@ public sealed class DeleteUserCommandHandler : CommandHandlerBase,
if (await CommitAsync())
{
- await Bus.RaiseEventAsync(new UserDeletedEvent(request.UserId));
+ await Bus.RaiseEventAsync(new UserDeletedEvent(request.UserId, user.TenantId));
}
}
}
\ No newline at end of file
diff --git a/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandHandler.cs
index 09ef27e..83ad83f 100644
--- a/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandHandler.cs
+++ b/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandHandler.cs
@@ -100,7 +100,7 @@ public sealed class UpdateUserCommandHandler : CommandHandlerBase,
if (await CommitAsync())
{
- await Bus.RaiseEventAsync(new UserUpdatedEvent(user.Id));
+ await Bus.RaiseEventAsync(new UserUpdatedEvent(user.Id, user.TenantId));
}
}
}
\ No newline at end of file
diff --git a/CleanArchitecture.Domain/EventHandler/TenantEventHandler.cs b/CleanArchitecture.Domain/EventHandler/TenantEventHandler.cs
index ed46c1a..7803f55 100644
--- a/CleanArchitecture.Domain/EventHandler/TenantEventHandler.cs
+++ b/CleanArchitecture.Domain/EventHandler/TenantEventHandler.cs
@@ -1,7 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
+using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Events.Tenant;
using MediatR;
+using Microsoft.Extensions.Caching.Distributed;
namespace CleanArchitecture.Domain.EventHandler;
@@ -10,18 +12,29 @@ public sealed class TenantEventHandler :
INotificationHandler,
INotificationHandler
{
+ private readonly IDistributedCache _distributedCache;
+
+ public TenantEventHandler(IDistributedCache distributedCache)
+ {
+ _distributedCache = distributedCache;
+ }
+
public Task Handle(TenantCreatedEvent notification, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
- public Task Handle(TenantDeletedEvent notification, CancellationToken cancellationToken)
+ public async Task Handle(TenantDeletedEvent notification, CancellationToken cancellationToken)
{
- return Task.CompletedTask;
+ await _distributedCache.RemoveAsync(
+ CacheKeyGenerator.GetEntityCacheKey(notification.AggregateId),
+ cancellationToken);
}
- public Task Handle(TenantUpdatedEvent notification, CancellationToken cancellationToken)
+ public async Task Handle(TenantUpdatedEvent notification, CancellationToken cancellationToken)
{
- return Task.CompletedTask;
+ await _distributedCache.RemoveAsync(
+ CacheKeyGenerator.GetEntityCacheKey(notification.AggregateId),
+ cancellationToken);
}
}
\ No newline at end of file
diff --git a/CleanArchitecture.Domain/EventHandler/UserEventHandler.cs b/CleanArchitecture.Domain/EventHandler/UserEventHandler.cs
index 3e29e28..4a2263a 100644
--- a/CleanArchitecture.Domain/EventHandler/UserEventHandler.cs
+++ b/CleanArchitecture.Domain/EventHandler/UserEventHandler.cs
@@ -1,7 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
+using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Events.User;
using MediatR;
+using Microsoft.Extensions.Caching.Distributed;
namespace CleanArchitecture.Domain.EventHandler;
@@ -11,23 +13,36 @@ public sealed class UserEventHandler :
INotificationHandler,
INotificationHandler
{
+ private readonly IDistributedCache _distributedCache;
+
+ public UserEventHandler(IDistributedCache distributedCache)
+ {
+ _distributedCache = distributedCache;
+ }
+
public Task Handle(PasswordChangedEvent notification, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
- public Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken)
+ public async Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken)
{
- return Task.CompletedTask;
+ await _distributedCache.RemoveAsync(
+ CacheKeyGenerator.GetEntityCacheKey(notification.TenantId),
+ cancellationToken);
}
- public Task Handle(UserDeletedEvent notification, CancellationToken cancellationToken)
+ public async Task Handle(UserDeletedEvent notification, CancellationToken cancellationToken)
{
- return Task.CompletedTask;
+ await _distributedCache.RemoveAsync(
+ CacheKeyGenerator.GetEntityCacheKey(notification.TenantId),
+ cancellationToken);
}
- public Task Handle(UserUpdatedEvent notification, CancellationToken cancellationToken)
+ public async Task Handle(UserUpdatedEvent notification, CancellationToken cancellationToken)
{
- return Task.CompletedTask;
+ await _distributedCache.RemoveAsync(
+ CacheKeyGenerator.GetEntityCacheKey(notification.TenantId),
+ cancellationToken);
}
}
\ No newline at end of file
diff --git a/CleanArchitecture.Domain/Events/User/UserCreatedEvent.cs b/CleanArchitecture.Domain/Events/User/UserCreatedEvent.cs
index 95d11bf..014c7ea 100644
--- a/CleanArchitecture.Domain/Events/User/UserCreatedEvent.cs
+++ b/CleanArchitecture.Domain/Events/User/UserCreatedEvent.cs
@@ -5,7 +5,10 @@ namespace CleanArchitecture.Domain.Events.User;
public sealed class UserCreatedEvent : DomainEvent
{
- public UserCreatedEvent(Guid userId) : base(userId)
+ public Guid TenantId { get; }
+
+ public UserCreatedEvent(Guid userId, Guid tenantId) : base(userId)
{
+ TenantId = tenantId;
}
}
\ No newline at end of file
diff --git a/CleanArchitecture.Domain/Events/User/UserDeletedEvent.cs b/CleanArchitecture.Domain/Events/User/UserDeletedEvent.cs
index 8b485f5..836ae5a 100644
--- a/CleanArchitecture.Domain/Events/User/UserDeletedEvent.cs
+++ b/CleanArchitecture.Domain/Events/User/UserDeletedEvent.cs
@@ -5,7 +5,10 @@ namespace CleanArchitecture.Domain.Events.User;
public sealed class UserDeletedEvent : DomainEvent
{
- public UserDeletedEvent(Guid userId) : base(userId)
+ public Guid TenantId { get; }
+
+ public UserDeletedEvent(Guid userId, Guid tenantId) : base(userId)
{
+ TenantId = tenantId;
}
}
\ No newline at end of file
diff --git a/CleanArchitecture.Domain/Events/User/UserUpdatedEvent.cs b/CleanArchitecture.Domain/Events/User/UserUpdatedEvent.cs
index 7056b95..e72c005 100644
--- a/CleanArchitecture.Domain/Events/User/UserUpdatedEvent.cs
+++ b/CleanArchitecture.Domain/Events/User/UserUpdatedEvent.cs
@@ -5,7 +5,10 @@ namespace CleanArchitecture.Domain.Events.User;
public sealed class UserUpdatedEvent : DomainEvent
{
- public UserUpdatedEvent(Guid userId) : base(userId)
+ public Guid TenantId { get; }
+
+ public UserUpdatedEvent(Guid userId, Guid tenantId) : base(userId)
{
+ TenantId = tenantId;
}
}
\ No newline at end of file
diff --git a/CleanArchitecture.Domain/Extensions/DistributedCacheExtensions.cs b/CleanArchitecture.Domain/Extensions/DistributedCacheExtensions.cs
new file mode 100644
index 0000000..fb0a5da
--- /dev/null
+++ b/CleanArchitecture.Domain/Extensions/DistributedCacheExtensions.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Caching.Distributed;
+using Newtonsoft.Json;
+
+namespace CleanArchitecture.Domain.Extensions;
+
+public static class DistributedCacheExtensions
+{
+ private static readonly JsonSerializerSettings s_jsonSerializerSettings = new()
+ {
+ TypeNameHandling = TypeNameHandling.All
+ };
+
+ public static async Task GetOrCreateJsonAsync(
+ this IDistributedCache cache,
+ string key,
+ Func> factory,
+ DistributedCacheEntryOptions options,
+ CancellationToken cancellationToken = default) where T : class
+ {
+ var json = await cache.GetStringAsync(key, cancellationToken);
+
+ if (!string.IsNullOrWhiteSpace(json))
+ {
+ return JsonConvert.DeserializeObject(json, s_jsonSerializerSettings)!;
+ }
+
+ var value = await factory();
+
+ if (value == default)
+ {
+ return value;
+ }
+
+ json = JsonConvert.SerializeObject(value, s_jsonSerializerSettings);
+
+ await cache.SetStringAsync(key, json, options, cancellationToken);
+
+ return value;
+ }
+}
\ No newline at end of file
diff --git a/CleanArchitecture.Infrastructure.Tests/InMemoryBusTests.cs b/CleanArchitecture.Infrastructure.Tests/InMemoryBusTests.cs
index 1ef559e..bd976e1 100644
--- a/CleanArchitecture.Infrastructure.Tests/InMemoryBusTests.cs
+++ b/CleanArchitecture.Infrastructure.Tests/InMemoryBusTests.cs
@@ -39,7 +39,7 @@ public sealed class InMemoryBusTests
var inMemoryBus = new InMemoryBus(mediator, domainEventStore);
- var userDeletedEvent = new UserDeletedEvent(Guid.NewGuid());
+ var userDeletedEvent = new UserDeletedEvent(Guid.NewGuid(), Guid.NewGuid());
await inMemoryBus.RaiseEventAsync(userDeletedEvent);
diff --git a/Readme.md b/Readme.md
index f73b60d..ada4b29 100644
--- a/Readme.md
+++ b/Readme.md
@@ -38,6 +38,19 @@ To run the project, follow these steps:
### Using docker
+Requirements
+> This is only needed if running the API locally or only the docker image
+1. Redis: `docker run --name redis -d -p 6379:6379 -e ALLOW_EMPTY_PASSWORD=yes redis:latest`
+2. Add this to the redis configuration in the Program.cs
+```csharp
+options.ConfigurationOptions = new ConfigurationOptions
+ {
+ AbortOnConnectFail = false,
+ EndPoints = { "localhost", "6379" }
+ };
+```
+
+Running the container
1. Build the Dockerfile: `docker build -t clean-architecture .`
2. Run the Container: `docker run -p 80:80 clean-architecture`
@@ -50,7 +63,8 @@ To run the project, follow these steps:
### Using Kubernetes
1. Change the ConnectionString in the appsettings.json to `Server=clean-architecture-db-service;Database=clean-architecture;Trusted_Connection=False;MultipleActiveResultSets=true;TrustServerCertificate=True;User Id=SA;Password=Password123!#`
-2. Build the docker image and push it to the docker hub (Change the image name in the `k8s-deployment.yml` to your own)
+2. Change the RedisHostName in the appsettings.json to `redis-service`
+3. Build the docker image and push it to the docker hub (Change the image name in the `k8s-deployment.yml` to your own)
Apply the deployment file: `kubectl apply -f k8s-deployment.yml` (Delete: `kubectl delete -f k8s-deployment.yml`)
diff --git a/docker-compose.yml b/docker-compose.yml
index 2eca5e4..d7b5f06 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -22,3 +22,16 @@ services:
- SA_PASSWORD=Password123!#
ports:
- 1433:1433
+ redis:
+ image: docker.io/bitnami/redis:7.2
+ environment:
+ # ALLOW_EMPTY_PASSWORD is recommended only for development.
+ - ALLOW_EMPTY_PASSWORD=yes
+ - REDIS_DISABLE_COMMANDS=FLUSHDB,FLUSHALL
+ ports:
+ - '6379:6379'
+ volumes:
+ - 'redis_data:/bitnami/redis/data'
+volumes:
+ redis_data:
+ driver: local
diff --git a/k8s-deployment.yml b/k8s-deployment.yml
index 03cec34..b36dd0a 100644
--- a/k8s-deployment.yml
+++ b/k8s-deployment.yml
@@ -14,6 +14,7 @@ spec:
spec:
containers:
- name: clean-architecture-app
+ # Replace this with the path to your built image
image: alexdev28/clean-architecture
ports:
- containerPort: 80
@@ -70,4 +71,50 @@ spec:
- protocol: TCP
port: 1433
targetPort: 1433
- type: ClusterIP
\ No newline at end of file
+ type: ClusterIP
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: redis-deployment
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: redis
+ template:
+ metadata:
+ labels:
+ app: redis
+ spec:
+ containers:
+ - name: redis
+ image: docker.io/bitnami/redis:7.2
+ env:
+ # ALLOW_EMPTY_PASSWORD is recommended only for development.
+ - name: ALLOW_EMPTY_PASSWORD
+ value: "yes"
+ - name: REDIS_DISABLE_COMMANDS
+ value: "FLUSHDB,FLUSHALL"
+ ports:
+ - containerPort: 6379
+ volumeMounts:
+ - name: redis-data
+ mountPath: /bitnami/redis/data
+ volumes:
+ - name: redis-data
+ emptyDir: {}
+
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: redis-service
+spec:
+ selector:
+ app: redis
+ ports:
+ - protocol: TCP
+ port: 6379
+ targetPort: 6379