feat: refinamentos
This commit is contained in:
@@ -3,8 +3,6 @@ version: '3.9'
|
||||
services:
|
||||
|
||||
shop-webapi:
|
||||
ports:
|
||||
- "5072:5072"
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Docker
|
||||
volumes:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Shop.Core.Abstractions;
|
||||
@@ -8,15 +7,11 @@ namespace Shop.Core.Abstractions;
|
||||
/// Repositório (somente escrita).
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">O tipo da entidade.</typeparam>
|
||||
public interface IWriteOnlyRepository<TEntity> : IDisposable
|
||||
where TEntity : IEntity<Guid>
|
||||
public interface IWriteOnlyRepository<TEntity> : IDisposable where TEntity : IEntity<Guid>
|
||||
{
|
||||
void Add(TEntity entity);
|
||||
void AddRange(IEnumerable<TEntity> entities);
|
||||
void Update(TEntity entity);
|
||||
void UpdateRange(IEnumerable<TEntity> entities);
|
||||
void Remove(TEntity entity);
|
||||
void RemoveRange(IEnumerable<TEntity> entities);
|
||||
|
||||
/// <summary>
|
||||
/// Obtém a entidade por Id.
|
||||
|
||||
@@ -10,21 +10,10 @@ namespace Shop.Core.Extensions;
|
||||
/// </summary>
|
||||
public static class JsonExtensions
|
||||
{
|
||||
private static readonly CamelCaseNamingStrategy NamingStrategy = new();
|
||||
private static readonly StringEnumConverter EnumConverter = new(NamingStrategy);
|
||||
private static readonly PrivateSetterContractResolver ContractResolver = new() { NamingStrategy = NamingStrategy };
|
||||
private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings().Configure();
|
||||
|
||||
public static JsonSerializerSettings Configure(this JsonSerializerSettings jsonSettings)
|
||||
{
|
||||
jsonSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
|
||||
jsonSettings.PreserveReferencesHandling = PreserveReferencesHandling.None;
|
||||
jsonSettings.NullValueHandling = NullValueHandling.Ignore;
|
||||
jsonSettings.Formatting = Formatting.None;
|
||||
jsonSettings.ContractResolver = ContractResolver;
|
||||
jsonSettings.Converters.Add(EnumConverter);
|
||||
return jsonSettings;
|
||||
}
|
||||
private static readonly CamelCaseNamingStrategy CamelCaseNaming = new();
|
||||
private static readonly StringEnumConverter StringEnumConverter = new(CamelCaseNaming);
|
||||
private static readonly PrivateSetterContractResolver ContractResolver = new() { NamingStrategy = CamelCaseNaming };
|
||||
private static readonly JsonSerializerSettings DefaultJsonSettings = new JsonSerializerSettings().Configure();
|
||||
|
||||
/// <summary>
|
||||
/// Desserializa o JSON para o tipo especificado.
|
||||
@@ -33,7 +22,7 @@ public static class JsonExtensions
|
||||
/// <param name="value">O objeto a ser desserializado.</param>
|
||||
/// <returns>O objeto desserializado da string JSON.</returns>
|
||||
public static T FromJson<T>(this string value) =>
|
||||
value != null ? JsonConvert.DeserializeObject<T>(value, JsonSettings) : default;
|
||||
value != null ? JsonConvert.DeserializeObject<T>(value, DefaultJsonSettings) : default;
|
||||
|
||||
/// <summary>
|
||||
/// Serializa o objeto especificado em uma string JSON.
|
||||
@@ -41,5 +30,16 @@ public static class JsonExtensions
|
||||
/// <param name="value">O objeto a ser serializado.</param>
|
||||
/// <returns>Uma representação de string JSON do objeto.</returns>
|
||||
public static string ToJson<T>(this T value) =>
|
||||
value != null ? JsonConvert.SerializeObject(value, JsonSettings) : default;
|
||||
value != null ? JsonConvert.SerializeObject(value, DefaultJsonSettings) : default;
|
||||
|
||||
public static JsonSerializerSettings Configure(this JsonSerializerSettings jsonSettings)
|
||||
{
|
||||
jsonSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
|
||||
jsonSettings.PreserveReferencesHandling = PreserveReferencesHandling.None;
|
||||
jsonSettings.NullValueHandling = NullValueHandling.Ignore;
|
||||
jsonSettings.Formatting = Formatting.None;
|
||||
jsonSettings.ContractResolver = ContractResolver;
|
||||
jsonSettings.Converters.Add(StringEnumConverter);
|
||||
return jsonSettings;
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,7 @@ public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest,
|
||||
{
|
||||
var commandName = request.GetGenericTypeName();
|
||||
|
||||
_logger.LogInformation(
|
||||
"----- Handling command {CommandName} ({Command})",
|
||||
commandName, request);
|
||||
_logger.LogInformation("----- Handling command {CommandName} ({Command})", commandName, request);
|
||||
|
||||
var timer = new Stopwatch();
|
||||
timer.Start();
|
||||
@@ -32,9 +30,8 @@ public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest,
|
||||
|
||||
timer.Stop();
|
||||
|
||||
_logger.LogInformation(
|
||||
"----- Command {CommandName} handled ({TimeTaken} seconds) - response: {Response} ",
|
||||
commandName, timer.Elapsed.Seconds, response);
|
||||
var timeTaken = timer.Elapsed.Seconds;
|
||||
_logger.LogInformation("----- Command {CommandName} handled ({TimeTaken} seconds)", commandName, timeTaken);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -14,5 +14,7 @@ public abstract class BaseDbContext<TContext> : DbContext
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
=> modelBuilder.UseCollation(Collation).RemoveCascadeDeleteConvention();
|
||||
=> modelBuilder
|
||||
.UseCollation(Collation)
|
||||
.RemoveCascadeDeleteConvention();
|
||||
}
|
||||
@@ -9,7 +9,7 @@ public static class ModelBuilderExtensions
|
||||
/// Remove a delação em cascata de chaves estrangeiras (FK).
|
||||
/// </summary>
|
||||
/// <param name="modelBuilder">The model builder.</param>
|
||||
public static ModelBuilder RemoveCascadeDeleteConvention(this ModelBuilder modelBuilder)
|
||||
public static void RemoveCascadeDeleteConvention(this ModelBuilder modelBuilder)
|
||||
{
|
||||
var foreignKeys = modelBuilder.Model
|
||||
.GetEntityTypes()
|
||||
@@ -19,7 +19,5 @@ public static class ModelBuilderExtensions
|
||||
|
||||
foreach (var fk in foreignKeys)
|
||||
fk.DeleteBehavior = DeleteBehavior.Restrict;
|
||||
|
||||
return modelBuilder;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shop.Core.Abstractions;
|
||||
@@ -22,21 +21,12 @@ internal abstract class BaseWriteOnlyRepository<TEntity> : IWriteOnlyRepository<
|
||||
public void Add(TEntity entity)
|
||||
=> DbSet.Add(entity);
|
||||
|
||||
public void AddRange(IEnumerable<TEntity> entities)
|
||||
=> DbSet.AddRange(entities);
|
||||
|
||||
public void Update(TEntity entity)
|
||||
=> DbSet.Update(entity);
|
||||
|
||||
public void UpdateRange(IEnumerable<TEntity> entities)
|
||||
=> DbSet.UpdateRange(entities);
|
||||
|
||||
public void Remove(TEntity entity)
|
||||
=> DbSet.Remove(entity);
|
||||
|
||||
public void RemoveRange(IEnumerable<TEntity> entities)
|
||||
=> DbSet.RemoveRange(entities);
|
||||
|
||||
public async Task<TEntity> GetByIdAsync(Guid id)
|
||||
=> await DbSet.AsNoTracking().FirstOrDefaultAsync(entity => entity.Id == id);
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ namespace Shop.Infrastructure.Data.Services;
|
||||
|
||||
internal class MemoryCacheService : ICacheService
|
||||
{
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly ILogger<MemoryCacheService> _logger;
|
||||
private readonly MemoryCacheEntryOptions _cacheOptions;
|
||||
private readonly ILogger<MemoryCacheService> _logger;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public MemoryCacheService(
|
||||
ILogger<MemoryCacheService> logger,
|
||||
@@ -32,7 +32,7 @@ internal class MemoryCacheService : ICacheService
|
||||
|
||||
public async Task<TItem> GetOrCreateAsync<TItem>(string cacheKey, Func<Task<TItem>> factory)
|
||||
{
|
||||
return await _memoryCache.GetOrCreateAsync(cacheKey, async (cacheEntry) =>
|
||||
return await _memoryCache.GetOrCreateAsync(cacheKey, async cacheEntry =>
|
||||
{
|
||||
var cacheValue = cacheEntry?.Value;
|
||||
if (cacheValue != null)
|
||||
@@ -52,9 +52,10 @@ internal class MemoryCacheService : ICacheService
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TItem>> GetOrCreateAsync<TItem>(string cacheKey, Func<Task<IEnumerable<TItem>>> factory)
|
||||
public async Task<IEnumerable<TItem>> GetOrCreateAsync<TItem>(string cacheKey,
|
||||
Func<Task<IEnumerable<TItem>>> factory)
|
||||
{
|
||||
return await _memoryCache.GetOrCreateAsync(cacheKey, async (cacheEntry) =>
|
||||
return await _memoryCache.GetOrCreateAsync(cacheKey, async cacheEntry =>
|
||||
{
|
||||
var cacheValues = cacheEntry?.Value;
|
||||
if (cacheValues != null)
|
||||
@@ -73,6 +74,7 @@ internal class MemoryCacheService : ICacheService
|
||||
return items;
|
||||
});
|
||||
}
|
||||
|
||||
public Task RemoveAsync(params string[] cacheKeys)
|
||||
{
|
||||
foreach (var cacheKey in cacheKeys)
|
||||
|
||||
@@ -53,6 +53,8 @@ public static class ServicesCollectionExtensions
|
||||
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
|
||||
options.IncludeXmlComments(xmlPath, true);
|
||||
});
|
||||
|
||||
services.AddSwaggerGenNewtonsoftSupport();
|
||||
}
|
||||
|
||||
public static void AddHealthChecks(this IServiceCollection services, IConfiguration configuration)
|
||||
@@ -127,9 +129,7 @@ public static class ServicesCollectionExtensions
|
||||
eventData =>
|
||||
{
|
||||
if (eventData is not ExecutionStrategyEventData retryEventData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var exceptions = retryEventData.ExceptionsEncountered;
|
||||
|
||||
|
||||
@@ -111,11 +111,12 @@ var app = builder.Build();
|
||||
if (app.Environment.IsDevelopment())
|
||||
app.UseDeveloperExceptionPage();
|
||||
|
||||
app.UseHealthChecks("/health", new HealthCheckOptions
|
||||
{
|
||||
AllowCachingResponses = false,
|
||||
ResponseWriter = (httpContext, healthReport) => httpContext.Response.WriteAsync(healthReport.ToJson())
|
||||
});
|
||||
app.UseHealthChecks("/health",
|
||||
new HealthCheckOptions
|
||||
{
|
||||
AllowCachingResponses = false,
|
||||
ResponseWriter = (httpContext, healthReport) => httpContext.Response.WriteAsync(healthReport.ToJson())
|
||||
});
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
app.UseResponseCompression();
|
||||
|
||||
@@ -8,8 +8,15 @@ namespace Shop.Query.Abstractions;
|
||||
public interface IReadDbContext
|
||||
{
|
||||
string ConnectionString { get; }
|
||||
IMongoCollection<TQueryModel> GetCollection<TQueryModel>() where TQueryModel : IQueryModel;
|
||||
Task UpsertAsync<TQueryModel>(TQueryModel queryModel, Expression<Func<TQueryModel, bool>> upsertFilter) where TQueryModel : IQueryModel;
|
||||
Task DeleteAsync<TQueryModel>(Expression<Func<TQueryModel, bool>> deleteFilter) where TQueryModel : IQueryModel;
|
||||
|
||||
IMongoCollection<TQueryModel> GetCollection<TQueryModel>()
|
||||
where TQueryModel : IQueryModel;
|
||||
|
||||
Task UpsertAsync<TQueryModel>(TQueryModel queryModel, Expression<Func<TQueryModel, bool>> upsertFilter)
|
||||
where TQueryModel : IQueryModel;
|
||||
|
||||
Task DeleteAsync<TQueryModel>(Expression<Func<TQueryModel, bool>> deleteFilter)
|
||||
where TQueryModel : IQueryModel;
|
||||
|
||||
Task CreateCollectionsAsync();
|
||||
}
|
||||
@@ -21,6 +21,22 @@ public sealed class ReadDbContext : IReadDbContext
|
||||
private const string DatabaseName = "Shop";
|
||||
private static readonly Random Rnd = new();
|
||||
|
||||
private static readonly ReplaceOptions DefaultReplaceOptions = new()
|
||||
{
|
||||
// Indica se o documento deve ser inserido se ele não existir.
|
||||
IsUpsert = true
|
||||
};
|
||||
|
||||
private static readonly CreateIndexOptions DefaultCreateIndexOptions = new()
|
||||
{
|
||||
// Restrição Exclusiva
|
||||
Unique = true,
|
||||
|
||||
// Índices esparsos são como índices não esparsos, exceto que eles omitem referências a documentos que não incluem o campo indexado.
|
||||
// Para campos que estão presentes apenas em alguns documentos, índices esparsos podem fornecer uma economia significativa de espaço.
|
||||
Sparse = true
|
||||
};
|
||||
|
||||
private readonly IMongoDatabase _database;
|
||||
private readonly ILogger<ReadDbContext> _logger;
|
||||
private readonly AsyncRetryPolicy _mongoRetryPolicy;
|
||||
@@ -29,8 +45,8 @@ public sealed class ReadDbContext : IReadDbContext
|
||||
{
|
||||
ConnectionString = options.Value.NoSqlConnection;
|
||||
|
||||
var client = new MongoClient(options.Value.NoSqlConnection);
|
||||
_database = client.GetDatabase(DatabaseName);
|
||||
var mongoClient = new MongoClient(options.Value.NoSqlConnection);
|
||||
_database = mongoClient.GetDatabase(DatabaseName);
|
||||
_logger = logger;
|
||||
_mongoRetryPolicy = CreateRetryPolicy(logger);
|
||||
}
|
||||
@@ -45,13 +61,9 @@ public sealed class ReadDbContext : IReadDbContext
|
||||
{
|
||||
var collection = GetCollection<TQueryModel>();
|
||||
|
||||
// ReplaceOptions:
|
||||
// Se o documento existir, será substituído.
|
||||
// Se o documento não existir, será criado um novo.
|
||||
var replaceOptions = new ReplaceOptions { IsUpsert = true };
|
||||
|
||||
await _mongoRetryPolicy
|
||||
.ExecuteAsync(async () => await collection.ReplaceOneAsync(upsertFilter, queryModel, replaceOptions));
|
||||
.ExecuteAsync(async () =>
|
||||
await collection.ReplaceOneAsync(upsertFilter, queryModel, DefaultReplaceOptions));
|
||||
}
|
||||
|
||||
public async Task DeleteAsync<TQueryModel>(Expression<Func<TQueryModel, bool>> deleteFilter)
|
||||
@@ -87,17 +99,7 @@ public sealed class ReadDbContext : IReadDbContext
|
||||
private async Task CreateIndexAsync()
|
||||
{
|
||||
var indexDefinition = Builders<CustomerQueryModel>.IndexKeys.Ascending(model => model.Email);
|
||||
|
||||
var indexModel = new CreateIndexModel<CustomerQueryModel>(indexDefinition, new CreateIndexOptions
|
||||
{
|
||||
// Restrição Exclusiva
|
||||
Unique = true,
|
||||
|
||||
// Índices esparsos são como índices não esparsos, exceto que eles omitem referências a documentos que não incluem o campo indexado.
|
||||
// Para campos que estão presentes apenas em alguns documentos, índices esparsos podem fornecer uma economia significativa de espaço.
|
||||
Sparse = true
|
||||
});
|
||||
|
||||
var indexModel = new CreateIndexModel<CustomerQueryModel>(indexDefinition, DefaultCreateIndexOptions);
|
||||
var collection = GetCollection<CustomerQueryModel>();
|
||||
await collection.Indexes.CreateOneAsync(indexModel);
|
||||
}
|
||||
@@ -109,22 +111,25 @@ public sealed class ReadDbContext : IReadDbContext
|
||||
.Select(impl => impl.Name)
|
||||
.ToList();
|
||||
|
||||
private static AsyncRetryPolicy CreateRetryPolicy(ILogger<ReadDbContext> logger)
|
||||
private static AsyncRetryPolicy CreateRetryPolicy(ILogger logger)
|
||||
{
|
||||
return Policy
|
||||
.Handle<MongoException>()
|
||||
.WaitAndRetryAsync(2, (retryAttempt) =>
|
||||
{
|
||||
// Retry with jitter
|
||||
// A well-known retry strategy is exponential backoff, allowing retries to be made initially quickly,
|
||||
// but then at progressively longer intervals: for example, after 2, 4, 8, 15, then 30 seconds.
|
||||
// REF: https://github.com/App-vNext/Polly/wiki/Retry-with-jitter#simple-jitter
|
||||
var sleepDuration
|
||||
= TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)) + TimeSpan.FromMilliseconds(Rnd.Next(0, 1000));
|
||||
TimeSpan SleepDurationProvider(int retryAttempt)
|
||||
{
|
||||
// Retry with jitter
|
||||
// A well-known retry strategy is exponential backoff, allowing retries to be made initially quickly,
|
||||
// but then at progressively longer intervals: for example, after 2, 4, 8, 15, then 30 seconds.
|
||||
// REF: https://github.com/App-vNext/Polly/wiki/Retry-with-jitter#simple-jitter
|
||||
var sleepDuration = TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)) +
|
||||
TimeSpan.FromMilliseconds(Rnd.Next(0, 1000));
|
||||
|
||||
logger.LogWarning("----- MongoDB: Retry #{Count} with delay {Delay}", retryAttempt, sleepDuration);
|
||||
logger.LogWarning("----- MongoDB: Retry #{Count} with delay {Delay}", retryAttempt, sleepDuration);
|
||||
|
||||
return sleepDuration;
|
||||
}, (ex, _) => logger.LogError(ex, "Ocorreu uma exceção não esperada ao salvar no MongoDB: {Message}", ex.Message));
|
||||
return sleepDuration;
|
||||
}
|
||||
|
||||
void OnRetry(Exception ex, TimeSpan _) => logger.LogError(ex,
|
||||
"Ocorreu uma exceção não esperada ao salvar no MongoDB: {Message}", ex.Message);
|
||||
|
||||
return Policy.Handle<MongoException>().WaitAndRetryAsync(2, SleepDurationProvider, OnRetry);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,10 @@ public class CustomerMap : IReadDbMapping
|
||||
|
||||
map.MapMember(customer => customer.DateOfBirth)
|
||||
.SetIsRequired(true)
|
||||
.SetSerializer(new DateTimeSerializer(dateOnly: true));
|
||||
.SetSerializer(new DateTimeSerializer(true));
|
||||
|
||||
// Ignore
|
||||
map.UnmapMember(m => m.FullName);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutoMapper;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shop.Core.Abstractions;
|
||||
using Shop.Core.Extensions;
|
||||
using Shop.Domain.Entities.CustomerAggregate.Events;
|
||||
using Shop.Query.Abstractions;
|
||||
using Shop.Query.Application.Customer.Queries;
|
||||
@@ -18,29 +20,27 @@ public class CustomerEventHandler :
|
||||
INotificationHandler<CustomerUpdatedEvent>,
|
||||
INotificationHandler<CustomerDeletedEvent>
|
||||
{
|
||||
private readonly ICacheService _cacheService;
|
||||
private readonly ILogger _logger;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly IReadDbContext _readDbContext;
|
||||
private readonly ICacheService _cacheService;
|
||||
|
||||
public CustomerEventHandler(
|
||||
IMapper mapper,
|
||||
IReadDbContext readDbContext,
|
||||
ICacheService cacheService)
|
||||
ICacheService cacheService,
|
||||
ILogger<CustomerEventHandler> logger)
|
||||
{
|
||||
_mapper = mapper;
|
||||
_readDbContext = readDbContext;
|
||||
_cacheService = cacheService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(CustomerCreatedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
var customerQueryModel = _mapper.Map<CustomerQueryModel>(notification);
|
||||
await _readDbContext.UpsertAsync(customerQueryModel, filter => filter.Id == customerQueryModel.Id);
|
||||
await ClearCacheAsync(notification);
|
||||
}
|
||||
LogEvent(notification);
|
||||
|
||||
public async Task Handle(CustomerUpdatedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
var customerQueryModel = _mapper.Map<CustomerQueryModel>(notification);
|
||||
await _readDbContext.UpsertAsync(customerQueryModel, filter => filter.Id == customerQueryModel.Id);
|
||||
await ClearCacheAsync(notification);
|
||||
@@ -48,7 +48,18 @@ public class CustomerEventHandler :
|
||||
|
||||
public async Task Handle(CustomerDeletedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
await _readDbContext.DeleteAsync<CustomerQueryModel>(filter => filter.Id == notification.Id);
|
||||
LogEvent(notification);
|
||||
|
||||
await _readDbContext.DeleteAsync<CustomerQueryModel>(filter => filter.Email == notification.Email);
|
||||
await ClearCacheAsync(notification);
|
||||
}
|
||||
|
||||
public async Task Handle(CustomerUpdatedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
LogEvent(notification);
|
||||
|
||||
var customerQueryModel = _mapper.Map<CustomerQueryModel>(notification);
|
||||
await _readDbContext.UpsertAsync(customerQueryModel, filter => filter.Id == customerQueryModel.Id);
|
||||
await ClearCacheAsync(notification);
|
||||
}
|
||||
|
||||
@@ -57,4 +68,10 @@ public class CustomerEventHandler :
|
||||
var cacheKeys = new[] { nameof(GetAllCustomerQuery), $"{nameof(GetCustomerByIdQuery)}_{@event.Id}" };
|
||||
await _cacheService.RemoveAsync(cacheKeys);
|
||||
}
|
||||
|
||||
private void LogEvent<TEvent>(TEvent @event) where TEvent : CustomerBaseEvent
|
||||
{
|
||||
_logger.LogInformation("----- Triggering the event {EventName}, model: {EventModel}",
|
||||
typeof(TEvent).Name, @event.ToJson());
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,6 @@ public class CustomerQueryModel : BaseQueryModel
|
||||
public string Gender { get; set; }
|
||||
public string Email { get; set; }
|
||||
public DateTime DateOfBirth { get; set; }
|
||||
|
||||
public string FullName => (FirstName + " " + LastName).Trim();
|
||||
}
|
||||
Reference in New Issue
Block a user