diff --git a/CleanArchitecture.Api/CleanArchitecture.Api.csproj b/CleanArchitecture.Api/CleanArchitecture.Api.csproj index 46ce31f..8589472 100644 --- a/CleanArchitecture.Api/CleanArchitecture.Api.csproj +++ b/CleanArchitecture.Api/CleanArchitecture.Api.csproj @@ -7,7 +7,23 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/CleanArchitecture.Api/Controllers/ApiController.cs b/CleanArchitecture.Api/Controllers/ApiController.cs new file mode 100644 index 0000000..d30048e --- /dev/null +++ b/CleanArchitecture.Api/Controllers/ApiController.cs @@ -0,0 +1,68 @@ +using System.Linq; +using System.Net; +using CleanArchitecture.Api.Models; +using CleanArchitecture.Domain.Errors; +using CleanArchitecture.Domain.Notifications; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace CleanArchitecture.Api.Controllers; + +public class ApiController : ControllerBase +{ + private readonly DomainNotificationHandler _notifications; + + protected ApiController(INotificationHandler notifications) + { + _notifications = (DomainNotificationHandler)notifications; + } + + protected new IActionResult Response(object? resultData = null) + { + if (!_notifications.HasNotifications()) + { + return Ok( + new ResponseMessage + { + Success = true, + Data = resultData + }); + } + + var message = new ResponseMessage + { + Success = false, + Errors = _notifications.GetNotifications().Select(n => n.Value), + DetailedErrors = _notifications.GetNotifications().Select(n => new DetailedError + { + Code = n.Code, + Data = n.Data + }) + }; + + return new ObjectResult(message) + { + StatusCode = (int)GetErrorStatusCode() + }; + } + + protected HttpStatusCode GetStatusCode() + { + if (!_notifications.GetNotifications().Any()) + { + return HttpStatusCode.OK; + } + + return GetErrorStatusCode(); + } + + protected HttpStatusCode GetErrorStatusCode() + { + if (_notifications.GetNotifications().Any(n => n.Code == ErrorCodes.ObjectNotFound)) + { + return HttpStatusCode.NotFound; + } + + return HttpStatusCode.BadRequest; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Api/Controllers/UserController.cs b/CleanArchitecture.Api/Controllers/UserController.cs new file mode 100644 index 0000000..859ee65 --- /dev/null +++ b/CleanArchitecture.Api/Controllers/UserController.cs @@ -0,0 +1,45 @@ +using System; +using CleanArchitecture.Domain.Notifications; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace CleanArchitecture.Api.Controllers; + +[ApiController] +[Route("[controller]")] +public class UserController : ApiController +{ + public UserController(NotificationHandler notifications) : base(notifications) + { + } + + [HttpGet] + public string GetAllUsersAsync() + { + return "test"; + } + + [HttpGet("{id}")] + public string GetUserByIdAsync([FromRoute] Guid id) + { + return "test"; + } + + [HttpPost] + public string CreateUserAsync() + { + return "test"; + } + + [HttpDelete("{id}")] + public string DeleteUserAsync([FromRoute] Guid id) + { + return "test"; + } + + [HttpPut] + public string UpdateUserAsync() + { + return "test"; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Api/Controllers/WeatherForecastController.cs b/CleanArchitecture.Api/Controllers/WeatherForecastController.cs deleted file mode 100644 index 470b2a4..0000000 --- a/CleanArchitecture.Api/Controllers/WeatherForecastController.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace CleanArchitecture.Api.Controllers; - -[ApiController] -[Route("[controller]")] -public class WeatherForecastController : ControllerBase -{ - private readonly ILogger _logger; - - public WeatherForecastController(ILogger logger) - { - _logger = logger; - } - - [HttpGet(Name = "GetWeatherForecast")] - public string Get() - { - return "test"; - } -} \ No newline at end of file diff --git a/CleanArchitecture.Api/Models/DetailedError.cs b/CleanArchitecture.Api/Models/DetailedError.cs new file mode 100644 index 0000000..a4f3bd7 --- /dev/null +++ b/CleanArchitecture.Api/Models/DetailedError.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace CleanArchitecture.Api.Models; + +public sealed class DetailedError +{ + [JsonPropertyName("code")] + public string Code { get; init; } = string.Empty; + + [JsonPropertyName("data")] + public object? Data { get; init; } +} \ No newline at end of file diff --git a/CleanArchitecture.Api/Models/ResponseMessage.cs b/CleanArchitecture.Api/Models/ResponseMessage.cs new file mode 100644 index 0000000..e425f55 --- /dev/null +++ b/CleanArchitecture.Api/Models/ResponseMessage.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; + +namespace CleanArchitecture.Api.Models; + +public sealed class ResponseMessage +{ + [JsonPropertyName("success")] + public bool Success { get; init; } + + [JsonPropertyName("errors")] + public IEnumerable? Errors { get; init; } + + [JsonPropertyName("detailedErrors")] + public IEnumerable DetailedErrors { get; init; } = Enumerable.Empty(); + + [JsonPropertyName("data")] + public T? Data { get; init; } +} \ No newline at end of file diff --git a/CleanArchitecture.Api/Program.cs b/CleanArchitecture.Api/Program.cs index a5c1260..df305b8 100644 --- a/CleanArchitecture.Api/Program.cs +++ b/CleanArchitecture.Api/Program.cs @@ -1,16 +1,28 @@ +using CleanArchitecture.Infrastructure.Database; using Microsoft.AspNetCore.Builder; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; var builder = WebApplication.CreateBuilder(args); -// Add services to the container. - builder.Services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +builder.Services.AddDbContext(options => +{ + options.UseLazyLoadingProxies(); + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"), + b => b.MigrationsAssembly("netgo.centralhub.TenantService.Infrastructure")); +}); + +builder.Services.AddMediatR(cfg => +{ + cfg.RegisterServicesFromAssemblies(typeof(Program).Assembly); +}); + var app = builder.Build(); // Configure the HTTP request pipeline. @@ -26,4 +38,7 @@ app.UseAuthorization(); app.MapControllers(); -app.Run(); \ No newline at end of file +app.Run(); + +// Needed for integration tests webapplication factory +public partial class Program { } \ No newline at end of file diff --git a/CleanArchitecture.Application.Tests/CleanArchitecture.Application.Tests.csproj b/CleanArchitecture.Application.Tests/CleanArchitecture.Application.Tests.csproj new file mode 100644 index 0000000..e3e890e --- /dev/null +++ b/CleanArchitecture.Application.Tests/CleanArchitecture.Application.Tests.csproj @@ -0,0 +1,24 @@ + + + + net7.0 + enable + enable + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/CleanArchitecture.Application.Tests/UnitTest1.cs b/CleanArchitecture.Application.Tests/UnitTest1.cs new file mode 100644 index 0000000..4f8971e --- /dev/null +++ b/CleanArchitecture.Application.Tests/UnitTest1.cs @@ -0,0 +1,9 @@ +namespace CleanArchitecture.Application.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + } +} \ No newline at end of file diff --git a/CleanArchitecture.Application.Tests/Usings.cs b/CleanArchitecture.Application.Tests/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/CleanArchitecture.Application.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/CleanArchitecture.Application/CleanArchitecture.Application.csproj b/CleanArchitecture.Application/CleanArchitecture.Application.csproj new file mode 100644 index 0000000..705670a --- /dev/null +++ b/CleanArchitecture.Application/CleanArchitecture.Application.csproj @@ -0,0 +1,17 @@ + + + + net7.0 + enable + + + + + + + + + + + + diff --git a/CleanArchitecture.Application/Extensions/ServiceCollectionExtension.cs b/CleanArchitecture.Application/Extensions/ServiceCollectionExtension.cs new file mode 100644 index 0000000..4cd5d74 --- /dev/null +++ b/CleanArchitecture.Application/Extensions/ServiceCollectionExtension.cs @@ -0,0 +1,22 @@ +using CleanArchitecture.Application.Interfaces; +using CleanArchitecture.Application.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace CleanArchitecture.Application.Extensions; + +public static class ServiceCollectionExtension +{ + public static IServiceCollection AddServices(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } + + public static IServiceCollection AddQueryHandlers(this IServiceCollection services) + { + // services.AddScoped, GetUserByIdQueryHandler>(); + + return services; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Application/Interfaces/IUserService.cs b/CleanArchitecture.Application/Interfaces/IUserService.cs new file mode 100644 index 0000000..434842e --- /dev/null +++ b/CleanArchitecture.Application/Interfaces/IUserService.cs @@ -0,0 +1,6 @@ +namespace CleanArchitecture.Application.Interfaces; + +public interface IUserService +{ + +} \ No newline at end of file diff --git a/CleanArchitecture.Application/Services/UserService.cs b/CleanArchitecture.Application/Services/UserService.cs new file mode 100644 index 0000000..5c3d82a --- /dev/null +++ b/CleanArchitecture.Application/Services/UserService.cs @@ -0,0 +1,7 @@ +using CleanArchitecture.Application.Interfaces; + +namespace CleanArchitecture.Application.Services; + +public sealed class UserService : IUserService +{ +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CleanArchitecture.Domain.Tests.csproj b/CleanArchitecture.Domain.Tests/CleanArchitecture.Domain.Tests.csproj new file mode 100644 index 0000000..7ae8e02 --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CleanArchitecture.Domain.Tests.csproj @@ -0,0 +1,30 @@ + + + + net7.0 + enable + enable + + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/CleanArchitecture.Domain.Tests/CommandHandlerFixtureBase.cs b/CleanArchitecture.Domain.Tests/CommandHandlerFixtureBase.cs new file mode 100644 index 0000000..ddf2bf7 --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandlerFixtureBase.cs @@ -0,0 +1,90 @@ +using System.Linq.Expressions; +using CleanArchitecture.Domain.Interfaces; +using CleanArchitecture.Domain.Notifications; +using Moq; + +namespace CleanArchitecture.Domain.Tests; + +public class CommandHandlerFixtureBase +{ + public Mock Bus { get; protected set; } + public Mock UnitOfWork { get; protected set; } + public Mock NotificationHandler { get; protected set; } + + protected CommandHandlerFixtureBase() + { + Bus = new Mock(); + UnitOfWork = new Mock(); + NotificationHandler = new Mock(); + + UnitOfWork.Setup(unit => unit.CommitAsync()).ReturnsAsync(true); + } + + public CommandHandlerFixtureBase VerifyExistingNotification(string errorCode, string message) + { + Bus.Verify( + bus => bus.RaiseEventAsync( + It.Is(not => not.Code == errorCode && not.Value == message)), + Times.Once); + + return this; + } + + public CommandHandlerFixtureBase VerifyAnyDomainNotification() + { + Bus.Verify( + bus => bus.RaiseEventAsync(It.IsAny()), + Times.Once); + + return this; + } + + public CommandHandlerFixtureBase VerifyNoDomainNotification() + { + Bus.Verify( + bus => bus.RaiseEventAsync(It.IsAny()), + Times.Never); + + return this; + } + + public CommandHandlerFixtureBase VerifyNoRaisedEvent() + where TEvent : DomainEvent + { + Bus.Verify( + bus => bus.RaiseEventAsync(It.IsAny()), + Times.Never); + + return this; + } + + public CommandHandlerFixtureBase VerifyNoRaisedEvent(Expression> checkFunction) + where TEvent : DomainEvent + { + Bus.Verify(bus => bus.RaiseEventAsync(It.Is(checkFunction)), Times.Never); + + return this; + } + + public CommandHandlerFixtureBase VerifyNoCommit() + { + UnitOfWork.Verify(unit => unit.CommitAsync(), Times.Never); + + return this; + } + + public CommandHandlerFixtureBase VerifyCommit() + { + UnitOfWork.Verify(unit => unit.CommitAsync(), Times.Once); + + return this; + } + + public CommandHandlerFixtureBase VerifyRaisedEvent(Expression> checkFunction) + where TEvent : DomainEvent + { + Bus.Verify(bus => bus.RaiseEventAsync(It.Is(checkFunction)), Times.Once); + + return this; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/UnitTest1.cs b/CleanArchitecture.Domain.Tests/UnitTest1.cs new file mode 100644 index 0000000..88b57e0 --- /dev/null +++ b/CleanArchitecture.Domain.Tests/UnitTest1.cs @@ -0,0 +1,11 @@ +using Xunit; + +namespace CleanArchitecture.Domain.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/ValidationTestBase.cs b/CleanArchitecture.Domain.Tests/ValidationTestBase.cs new file mode 100644 index 0000000..a039d3d --- /dev/null +++ b/CleanArchitecture.Domain.Tests/ValidationTestBase.cs @@ -0,0 +1,71 @@ +using CleanArchitecture.Domain.Commands; +using FluentAssertions; +using FluentValidation; + +namespace CleanArchitecture.Domain.Tests; + +public class ValidationTestBase + where TCommand : CommandBase + where TValidation: AbstractValidator +{ + protected readonly TValidation _validation; + + protected ValidationTestBase(TValidation validation) + { + _validation = validation; + } + + protected void ShouldBeValid(TCommand command) + { + var result = _validation.Validate(command); + + result.IsValid.Should().BeTrue(); + result.Errors.Should().BeEmpty(); + } + + protected void ShouldHaveSingleError( + TCommand command, + string expectedCode) + { + var result = _validation.Validate(command); + + result.IsValid.Should().BeFalse(); + + result.Errors.Count.Should().Be(1); + + result.Errors.First().ErrorCode.Should().Be(expectedCode); + } + + protected void ShouldHaveSingleError( + TCommand command, + string expectedCode, + string expectedMessage) + { + var result = _validation.Validate(command); + + result.IsValid.Should().BeFalse(); + + result.Errors.Count.Should().Be(1); + + result.Errors.First().ErrorCode.Should().Be(expectedCode); + result.Errors.First().ErrorMessage.Should().Be(expectedMessage); + } + + protected void ShouldHaveExpectedErrors( + TCommand command, + params KeyValuePair[] expectedErrors) + { + var result = _validation.Validate(command); + + result.IsValid.Should().BeFalse(); + result.Errors.Count.Should().Be(expectedErrors.Length); + + foreach (var error in expectedErrors) + { + result.Errors + .Count(validation => validation.ErrorCode == error.Key && validation.ErrorMessage == error.Value) + .Should() + .Be(1); + } + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj b/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj new file mode 100644 index 0000000..8c0626a --- /dev/null +++ b/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj @@ -0,0 +1,13 @@ + + + + net7.0 + enable + + + + + + + + diff --git a/CleanArchitecture.Domain/Commands/CommandBase.cs b/CleanArchitecture.Domain/Commands/CommandBase.cs new file mode 100644 index 0000000..25f84f6 --- /dev/null +++ b/CleanArchitecture.Domain/Commands/CommandBase.cs @@ -0,0 +1,22 @@ +using System; +using FluentValidation.Results; +using MediatR; + +namespace CleanArchitecture.Domain.Commands; + +public abstract class CommandBase : IRequest +{ + public Guid AggregateId { get; } + public string MessageType { get; } + public DateTime Timestamp { get; } + public ValidationResult? ValidationResult { get; protected set; } + + protected CommandBase(Guid aggregateId) + { + MessageType = GetType().Name; + Timestamp = DateTime.Now; + AggregateId = aggregateId; + } + + public abstract bool IsValid(); +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/DomainEvent.cs b/CleanArchitecture.Domain/DomainEvent.cs new file mode 100644 index 0000000..678edd7 --- /dev/null +++ b/CleanArchitecture.Domain/DomainEvent.cs @@ -0,0 +1,14 @@ +using System; +using MediatR; + +namespace CleanArchitecture.Domain; + +public abstract class DomainEvent : INotification +{ + protected DomainEvent(Guid aggregateId) + { + Timestamp = DateTime.Now; + } + + private DateTime Timestamp { get; } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Entities/Entity.cs b/CleanArchitecture.Domain/Entities/Entity.cs new file mode 100644 index 0000000..395e70e --- /dev/null +++ b/CleanArchitecture.Domain/Entities/Entity.cs @@ -0,0 +1,61 @@ +using System; + +namespace CleanArchitecture.Domain.Entities; + +public abstract class Entity +{ + protected Entity(Guid id) + { + Id = id; + } + + public Guid Id { get; private set; } + public bool Deleted { get; private set; } + + public void SetId(Guid id) + { + if (id == Guid.Empty) + { + throw new ArgumentException($"{nameof(id)} may not be empty"); + } + + Id = id; + } + + public void Delete() + { + Deleted = true; + } + + public void Undelete() + { + Deleted = false; + } + + public override bool Equals(object? obj) + { + var compareTo = obj as Entity; + + if (ReferenceEquals(this, compareTo)) + { + return true; + } + + if (compareTo is null) + { + return false; + } + + return Id == compareTo.Id; + } + + public override int GetHashCode() + { + return GetType().GetHashCode() * 907 + Id.GetHashCode(); + } + + public override string ToString() + { + return GetType().Name + " [Id=" + Id + "]"; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Entities/User.cs b/CleanArchitecture.Domain/Entities/User.cs new file mode 100644 index 0000000..731a8f9 --- /dev/null +++ b/CleanArchitecture.Domain/Entities/User.cs @@ -0,0 +1,75 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace CleanArchitecture.Domain.Entities; + +public class User : Entity +{ + public string Email { get; private set; } + public string GivenName { get; private set; } + public string Surname { get; private set; } + + public string FullName => $"{Surname}, {GivenName}"; + + public User( + Guid id, + string email, + string givenName, + string surname) : base(id) + { + Email = email; + GivenName = givenName; + Surname = surname; + } + + [MemberNotNull(nameof(Email))] + public void SetEmail(string email) + { + if (email == null) + { + throw new ArgumentNullException(nameof(email)); + } + + if (email.Length > 320) + { + throw new ArgumentException( + "Email may not be longer than 320 characters."); + } + + Email = email; + } + + [MemberNotNull(nameof(GivenName))] + public void SetGivenName(string givenName) + { + if (givenName == null) + { + throw new ArgumentNullException(nameof(givenName)); + } + + if (givenName.Length > 100) + { + throw new ArgumentException( + "Given name may not be longer than 100 characters"); + } + + GivenName = givenName; + } + + [MemberNotNull(nameof(Surname))] + public void SetSurname(string surname) + { + if (surname == null) + { + throw new ArgumentNullException(nameof(surname)); + } + + if (surname.Length > 100) + { + throw new ArgumentException( + "Surname may not be longer than 100 characters"); + } + + Surname = surname; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Errors/ErrorCodes.cs b/CleanArchitecture.Domain/Errors/ErrorCodes.cs new file mode 100644 index 0000000..056c188 --- /dev/null +++ b/CleanArchitecture.Domain/Errors/ErrorCodes.cs @@ -0,0 +1,7 @@ +namespace CleanArchitecture.Domain.Errors; + +public static class ErrorCodes +{ + public const string CommitFailed = "COMMIT_FAILED"; + public const string ObjectNotFound = "OBJECT_NOT_FOUND"; +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Interfaces/IMediatorHandler.cs b/CleanArchitecture.Domain/Interfaces/IMediatorHandler.cs new file mode 100644 index 0000000..22ca8ce --- /dev/null +++ b/CleanArchitecture.Domain/Interfaces/IMediatorHandler.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; +using CleanArchitecture.Domain.Commands; +using MediatR; + +namespace CleanArchitecture.Domain.Interfaces; + +public interface IMediatorHandler +{ + Task RaiseEventAsync(T @event) where T : DomainEvent; + + Task SendCommandAsync(T command) where T : CommandBase; + + Task QueryAsync(IRequest query); +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Interfaces/IUnitOfWork.cs b/CleanArchitecture.Domain/Interfaces/IUnitOfWork.cs new file mode 100644 index 0000000..d086427 --- /dev/null +++ b/CleanArchitecture.Domain/Interfaces/IUnitOfWork.cs @@ -0,0 +1,9 @@ +using System; +using System.Threading.Tasks; + +namespace CleanArchitecture.Domain.Interfaces; + +public interface IUnitOfWork : IDisposable +{ + public Task CommitAsync(); +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Interfaces/Repositories/IRepository.cs b/CleanArchitecture.Domain/Interfaces/Repositories/IRepository.cs new file mode 100644 index 0000000..126ee6d --- /dev/null +++ b/CleanArchitecture.Domain/Interfaces/Repositories/IRepository.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CleanArchitecture.Domain.Entities; + +namespace CleanArchitecture.Domain.Interfaces.Repositories; + +public interface IRepository : IDisposable where TEntity : Entity +{ + void Add(TEntity entity); + + void AddRange(IEnumerable entities); + + IQueryable GetAll(); + + IQueryable GetAllNoTracking(); + + Task GetByIdAsync(Guid id); + + void Update(TEntity entity); + + Task ExistsAsync(Guid id); +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Interfaces/Repositories/IUserRepository.cs b/CleanArchitecture.Domain/Interfaces/Repositories/IUserRepository.cs new file mode 100644 index 0000000..f9c7ede --- /dev/null +++ b/CleanArchitecture.Domain/Interfaces/Repositories/IUserRepository.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; +using CleanArchitecture.Domain.Entities; + +namespace CleanArchitecture.Domain.Interfaces.Repositories; + +public interface IUserRepository : IRepository +{ + Task GetByEmailAsync(string email); +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Notifications/DomainNotification.cs b/CleanArchitecture.Domain/Notifications/DomainNotification.cs new file mode 100644 index 0000000..c9b2719 --- /dev/null +++ b/CleanArchitecture.Domain/Notifications/DomainNotification.cs @@ -0,0 +1,26 @@ +using System; + +namespace CleanArchitecture.Domain.Notifications; + +public sealed class DomainNotification : DomainEvent +{ + public string Key { get; private set; } + public string Value { get; private set; } + public string Code { get; private set; } + public object? Data { get; set; } + + public DomainNotification( + string key, + string value, + string code, + object? data = null, + Guid? aggregateId = null) + : base(aggregateId ?? Guid.Empty) + { + Key = key ?? throw new ArgumentNullException(nameof(key)); + Value = value ?? throw new ArgumentNullException(nameof(value)); + Code = code ?? throw new ArgumentNullException(nameof(code)); + + Data = data; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Notifications/DomainNotificationHandler.cs b/CleanArchitecture.Domain/Notifications/DomainNotificationHandler.cs new file mode 100644 index 0000000..d1e579d --- /dev/null +++ b/CleanArchitecture.Domain/Notifications/DomainNotificationHandler.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace CleanArchitecture.Domain.Notifications; + +public class DomainNotificationHandler : INotificationHandler +{ + private readonly List _notifications; + + public DomainNotificationHandler() + { + _notifications = new List(); + } + + public virtual List GetNotifications() + { + return _notifications; + } + + public Task Handle(DomainNotification notification, CancellationToken cancellationToken = default) + { + _notifications.Add(notification); + + return Task.CompletedTask; + } + + public virtual bool HasNotifications() + { + return GetNotifications().Any(); + } + + public virtual void Clear() + { + _notifications.Clear(); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure.Tests/CleanArchitecture.Infrastructure.Tests.csproj b/CleanArchitecture.Infrastructure.Tests/CleanArchitecture.Infrastructure.Tests.csproj new file mode 100644 index 0000000..e3e890e --- /dev/null +++ b/CleanArchitecture.Infrastructure.Tests/CleanArchitecture.Infrastructure.Tests.csproj @@ -0,0 +1,24 @@ + + + + net7.0 + enable + enable + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/CleanArchitecture.Infrastructure.Tests/UnitTest1.cs b/CleanArchitecture.Infrastructure.Tests/UnitTest1.cs new file mode 100644 index 0000000..3afe4ef --- /dev/null +++ b/CleanArchitecture.Infrastructure.Tests/UnitTest1.cs @@ -0,0 +1,9 @@ +namespace CleanArchitecture.Infrastructure.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + } +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure.Tests/Usings.cs b/CleanArchitecture.Infrastructure.Tests/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/CleanArchitecture.Infrastructure.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/CleanArchitecture.Infrastructure.csproj b/CleanArchitecture.Infrastructure/CleanArchitecture.Infrastructure.csproj new file mode 100644 index 0000000..8dee848 --- /dev/null +++ b/CleanArchitecture.Infrastructure/CleanArchitecture.Infrastructure.csproj @@ -0,0 +1,17 @@ + + + + net7.0 + enable + + + + + + + + + + + + diff --git a/CleanArchitecture.Infrastructure/Configurations/UserConfiguration.cs b/CleanArchitecture.Infrastructure/Configurations/UserConfiguration.cs new file mode 100644 index 0000000..2723e12 --- /dev/null +++ b/CleanArchitecture.Infrastructure/Configurations/UserConfiguration.cs @@ -0,0 +1,26 @@ +using CleanArchitecture.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CleanArchitecture.Infrastructure.Configurations; + +public sealed class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder + .Property(user => user.Email) + .IsRequired() + .HasMaxLength(320); + + builder + .Property(user => user.GivenName) + .IsRequired() + .HasMaxLength(100); + + builder + .Property(user => user.Surname) + .IsRequired() + .HasMaxLength(100); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/Database/ApplicationDbContext.cs b/CleanArchitecture.Infrastructure/Database/ApplicationDbContext.cs new file mode 100644 index 0000000..238da23 --- /dev/null +++ b/CleanArchitecture.Infrastructure/Database/ApplicationDbContext.cs @@ -0,0 +1,19 @@ +using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Infrastructure.Configurations; +using Microsoft.EntityFrameworkCore; + +namespace CleanArchitecture.Infrastructure.Database; + +public sealed class ApplicationDbContext : DbContext +{ + public DbSet Users { get; set; } = null!; + + public ApplicationDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.ApplyConfiguration(new UserConfiguration()); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/InMemoryBus.cs b/CleanArchitecture.Infrastructure/InMemoryBus.cs new file mode 100644 index 0000000..5896d83 --- /dev/null +++ b/CleanArchitecture.Infrastructure/InMemoryBus.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; +using CleanArchitecture.Domain; +using CleanArchitecture.Domain.Commands; +using CleanArchitecture.Domain.Interfaces; +using MediatR; + +namespace CleanArchitecture.Infrastructure; + +public sealed class InMemoryBus : IMediatorHandler +{ + private readonly IMediator _mediator; + + public InMemoryBus(IMediator mediator) + { + _mediator = mediator; + } + + public Task QueryAsync(IRequest query) + { + return _mediator.Send(query); + } + + public async Task RaiseEventAsync(T @event) where T : DomainEvent + { + // await _domainEventStore.SaveAsync(@event); + + await _mediator.Publish(@event); + } + + public Task SendCommandAsync(T command) where T : CommandBase + { + return _mediator.Send(command); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/Repositories/BaseRepository.cs b/CleanArchitecture.Infrastructure/Repositories/BaseRepository.cs new file mode 100644 index 0000000..bde842f --- /dev/null +++ b/CleanArchitecture.Infrastructure/Repositories/BaseRepository.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Interfaces; +using CleanArchitecture.Domain.Interfaces.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace CleanArchitecture.Infrastructure.Repositories; + +public class BaseRepository : IRepository where TEntity : Entity +{ + protected readonly DbContext _dbContext; + protected readonly DbSet _dbSet; + + public BaseRepository(DbContext context) + { + _dbContext = context; + _dbSet = _dbContext.Set(); + } + + public void Add(TEntity entity) + { + _dbSet.Add(entity); + } + + public void AddRange(IEnumerable entities) + { + _dbSet.AddRange(entities); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + public virtual IQueryable GetAll() + { + return _dbSet; + } + + public virtual IQueryable GetAllNoTracking() + { + return _dbSet.AsNoTracking(); + } + + public virtual async Task GetByIdAsync(Guid id) + { + return await _dbSet.FindAsync(id); + } + + public int SaveChanges() + { + return _dbContext.SaveChanges(); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _dbContext.Dispose(); + } + } + + public virtual void Update(TEntity entity) + { + _dbSet.Update(entity); + } + + public Task ExistsAsync(Guid id) + { + return _dbSet.AnyAsync(entity => entity.Id == id); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/Repositories/UserRepository.cs b/CleanArchitecture.Infrastructure/Repositories/UserRepository.cs new file mode 100644 index 0000000..1fb07a7 --- /dev/null +++ b/CleanArchitecture.Infrastructure/Repositories/UserRepository.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Interfaces.Repositories; +using CleanArchitecture.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; + +namespace CleanArchitecture.Infrastructure.Repositories; + +public sealed class UserRepository : BaseRepository, IUserRepository +{ + public UserRepository(ApplicationDbContext context) : base(context) + { + } + + public async Task GetByEmailAsync(string email) + { + return await _dbSet.SingleOrDefaultAsync(user => user.Email == email); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/UnitOfWork.cs b/CleanArchitecture.Infrastructure/UnitOfWork.cs new file mode 100644 index 0000000..0525866 --- /dev/null +++ b/CleanArchitecture.Infrastructure/UnitOfWork.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading.Tasks; +using CleanArchitecture.Domain.Interfaces; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace CleanArchitecture.Infrastructure; + +public class UnitOfWork : IUnitOfWork where TContext : DbContext +{ + private readonly TContext _context; + private readonly ILogger> _logger; + + public UnitOfWork(TContext context, ILogger> logger) + { + _context = context; + _logger = logger; + } + + public async Task CommitAsync() + { + try + { + await _context.SaveChangesAsync(); + return true; + } + catch (DbUpdateException dbUpdateException) + { + _logger.LogError(dbUpdateException, "An error occured during commiting changes"); + return false; + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _context.Dispose(); + } + } +} diff --git a/CleanArchitecture.IntegrationTests/CleanArchitecture.IntegrationTests.csproj b/CleanArchitecture.IntegrationTests/CleanArchitecture.IntegrationTests.csproj new file mode 100644 index 0000000..e3e890e --- /dev/null +++ b/CleanArchitecture.IntegrationTests/CleanArchitecture.IntegrationTests.csproj @@ -0,0 +1,24 @@ + + + + net7.0 + enable + enable + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/CleanArchitecture.IntegrationTests/UnitTest1.cs b/CleanArchitecture.IntegrationTests/UnitTest1.cs new file mode 100644 index 0000000..e159791 --- /dev/null +++ b/CleanArchitecture.IntegrationTests/UnitTest1.cs @@ -0,0 +1,9 @@ +namespace CleanArchitecture.IntegrationTests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + } +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Usings.cs b/CleanArchitecture.IntegrationTests/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/CleanArchitecture.IntegrationTests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/CleanArchitecture.sln b/CleanArchitecture.sln index 31504dd..22483d0 100644 --- a/CleanArchitecture.sln +++ b/CleanArchitecture.sln @@ -2,6 +2,20 @@ Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Api", "CleanArchitecture.Api\CleanArchitecture.Api.csproj", "{CD720672-0ED9-4FDD-AD69-A416CB394318}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Application", "CleanArchitecture.Application\CleanArchitecture.Application.csproj", "{859B50AF-9C8D-4489-B64A-EEBDF756A012}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Domain", "CleanArchitecture.Domain\CleanArchitecture.Domain.csproj", "{12C5BEEF-9BFD-450A-8627-6205702CA32B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Infrastructure", "CleanArchitecture.Infrastructure\CleanArchitecture.Infrastructure.csproj", "{B6D046D8-D84A-4B7E-B05B-310B85EC8F1C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Application.Tests", "CleanArchitecture.Application.Tests\CleanArchitecture.Application.Tests.csproj", "{6794B922-2AFD-4187-944D-7984B9973259}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Domain.Tests", "CleanArchitecture.Domain.Tests\CleanArchitecture.Domain.Tests.csproj", "{E1F25916-EBBE-4CBD-99A2-1EB2F604D55C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.Infrastructure.Tests", "CleanArchitecture.Infrastructure.Tests\CleanArchitecture.Infrastructure.Tests.csproj", "{EC8122EB-C5E0-452F-B1CE-DA47DAEBC8F2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CleanArchitecture.IntegrationTests", "CleanArchitecture.IntegrationTests\CleanArchitecture.IntegrationTests.csproj", "{39732BD4-909F-410C-8737-1F9FE3E269A7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -12,5 +26,33 @@ Global {CD720672-0ED9-4FDD-AD69-A416CB394318}.Debug|Any CPU.Build.0 = Debug|Any CPU {CD720672-0ED9-4FDD-AD69-A416CB394318}.Release|Any CPU.ActiveCfg = Release|Any CPU {CD720672-0ED9-4FDD-AD69-A416CB394318}.Release|Any CPU.Build.0 = Release|Any CPU + {859B50AF-9C8D-4489-B64A-EEBDF756A012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {859B50AF-9C8D-4489-B64A-EEBDF756A012}.Debug|Any CPU.Build.0 = Debug|Any CPU + {859B50AF-9C8D-4489-B64A-EEBDF756A012}.Release|Any CPU.ActiveCfg = Release|Any CPU + {859B50AF-9C8D-4489-B64A-EEBDF756A012}.Release|Any CPU.Build.0 = Release|Any CPU + {12C5BEEF-9BFD-450A-8627-6205702CA32B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {12C5BEEF-9BFD-450A-8627-6205702CA32B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {12C5BEEF-9BFD-450A-8627-6205702CA32B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {12C5BEEF-9BFD-450A-8627-6205702CA32B}.Release|Any CPU.Build.0 = Release|Any CPU + {B6D046D8-D84A-4B7E-B05B-310B85EC8F1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B6D046D8-D84A-4B7E-B05B-310B85EC8F1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B6D046D8-D84A-4B7E-B05B-310B85EC8F1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B6D046D8-D84A-4B7E-B05B-310B85EC8F1C}.Release|Any CPU.Build.0 = Release|Any CPU + {6794B922-2AFD-4187-944D-7984B9973259}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6794B922-2AFD-4187-944D-7984B9973259}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6794B922-2AFD-4187-944D-7984B9973259}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6794B922-2AFD-4187-944D-7984B9973259}.Release|Any CPU.Build.0 = Release|Any CPU + {E1F25916-EBBE-4CBD-99A2-1EB2F604D55C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E1F25916-EBBE-4CBD-99A2-1EB2F604D55C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E1F25916-EBBE-4CBD-99A2-1EB2F604D55C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E1F25916-EBBE-4CBD-99A2-1EB2F604D55C}.Release|Any CPU.Build.0 = Release|Any CPU + {EC8122EB-C5E0-452F-B1CE-DA47DAEBC8F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EC8122EB-C5E0-452F-B1CE-DA47DAEBC8F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EC8122EB-C5E0-452F-B1CE-DA47DAEBC8F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EC8122EB-C5E0-452F-B1CE-DA47DAEBC8F2}.Release|Any CPU.Build.0 = Release|Any CPU + {39732BD4-909F-410C-8737-1F9FE3E269A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {39732BD4-909F-410C-8737-1F9FE3E269A7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {39732BD4-909F-410C-8737-1F9FE3E269A7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {39732BD4-909F-410C-8737-1F9FE3E269A7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal