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