From a42a47799571f400dcc653b71e4c9b6896e4d698 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 14:30:01 +0000 Subject: [PATCH 1/5] Initial plan From 7ea649c28084ad9eed3b386d3f947e98d464ac40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 14:37:37 +0000 Subject: [PATCH 2/5] Implement DDD structure in Blog.Core with Domain and Application layers Co-authored-by: xirzo <43218935+xirzo@users.noreply.github.com> --- .../Application/Services/JwtService.cs | 40 ++++++++++ .../Application/Services/UserService.cs | 78 +++++++++++++++++++ Backend/Blog.Core/Domain/Entities/Post.cs | 76 ++++++++++++++++++ Backend/Blog.Core/Domain/Entities/User.cs | 76 ++++++++++++++++++ Backend/Blog.Core/Domain/Permissions.cs | 8 ++ .../Domain/Repositories/IPostRepository.cs | 13 ++++ .../Domain/Repositories/IUserRepository.cs | 11 +++ .../Blog.Core/Domain/ValueObjects/Email.cs | 30 +++++++ .../Domain/ValueObjects/HashedPassword.cs | 38 +++++++++ Backend/Blog.IO/Db/BlogDbContext.cs | 16 +++- .../Blog.IO/Repositories/DbPostRepository.cs | 40 +++------- .../Blog.IO/Repositories/DbUserRepository.cs | 6 +- .../PermissionRequirementsHandler.cs | 2 +- .../Blog.Web/Controllers/AuthController.cs | 12 +-- .../Blog.Web/Controllers/PostsController.cs | 45 ++++++----- Backend/Blog.Web/Program.cs | 6 +- 16 files changed, 432 insertions(+), 65 deletions(-) create mode 100644 Backend/Blog.Core/Application/Services/JwtService.cs create mode 100644 Backend/Blog.Core/Application/Services/UserService.cs create mode 100644 Backend/Blog.Core/Domain/Entities/Post.cs create mode 100644 Backend/Blog.Core/Domain/Entities/User.cs create mode 100644 Backend/Blog.Core/Domain/Permissions.cs create mode 100644 Backend/Blog.Core/Domain/Repositories/IPostRepository.cs create mode 100644 Backend/Blog.Core/Domain/Repositories/IUserRepository.cs create mode 100644 Backend/Blog.Core/Domain/ValueObjects/Email.cs create mode 100644 Backend/Blog.Core/Domain/ValueObjects/HashedPassword.cs diff --git a/Backend/Blog.Core/Application/Services/JwtService.cs b/Backend/Blog.Core/Application/Services/JwtService.cs new file mode 100644 index 0000000..48b1dde --- /dev/null +++ b/Backend/Blog.Core/Application/Services/JwtService.cs @@ -0,0 +1,40 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Blog.Core.Domain.Entities; +using Blog.Core.Helpers; +using Microsoft.IdentityModel.Tokens; + +namespace Blog.Core.Application.Services; + +public class JwtService +{ + public string GenerateJwtToken(User user) + { + var claims = new[] + { + new Claim("guid", user.Id.ToString()), + new Claim(JwtRegisteredClaimNames.Email, user.Email.Value), + new Claim("name", user.Name), + }; + var jwtKey = EnvironmentHelper.GetEnvironmentVariableOrFile("JWT_KEY"); + + if (jwtKey == null) + { + throw new InvalidOperationException("JWT_KEY environment variable is not set."); + } + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: EnvironmentHelper.GetEnvironmentVariableOrFile("JWT_ISSUER"), + audience: EnvironmentHelper.GetEnvironmentVariableOrFile("JWT_AUDIENCE"), + claims: claims, + expires: DateTime.UtcNow.AddHours(2), + signingCredentials: credentials + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/Backend/Blog.Core/Application/Services/UserService.cs b/Backend/Blog.Core/Application/Services/UserService.cs new file mode 100644 index 0000000..f58250b --- /dev/null +++ b/Backend/Blog.Core/Application/Services/UserService.cs @@ -0,0 +1,78 @@ +using Blog.Core.Domain.Entities; +using Blog.Core.Domain.Repositories; + +namespace Blog.Core.Application.Services; + +public abstract record RegisterResult +{ + public record Success(User User) : RegisterResult; + public record UserAlreadyExists(string Message) : RegisterResult; + public record UserRepositoryError(string Message) : RegisterResult; +} + +public abstract record LoginResult +{ + public record Success(string Token, User User) : LoginResult; + public record WrongPassword(string Message) : LoginResult; + public record UserNotFound(string Message) : LoginResult; +} + +public class UserService +{ + private readonly IUserRepository _userRepository; + private readonly JwtService _jwtService; + + public UserService(IUserRepository userRepository, JwtService jwtService) + { + _userRepository = userRepository; + _jwtService = jwtService; + } + + public async Task Register(string name, string email, string password) + { + var existingUser = await _userRepository.FindByEmailAsync(email); + + if (existingUser != null) + { + return new RegisterResult.UserAlreadyExists("User already exists with this email."); + } + + User user; + try + { + user = User.Create(name, email, password); + } + catch (ArgumentException ex) + { + return new RegisterResult.UserRepositoryError(ex.Message); + } + + var addedUser = await _userRepository.AddAsync(user); + + if (addedUser == null) + { + return new RegisterResult.UserRepositoryError("Failed to add user to the repository."); + } + + return new RegisterResult.Success(addedUser); + } + + public async Task Login(string email, string password) + { + var user = await _userRepository.FindByEmailAsync(email); + + if (user == null) + { + return new LoginResult.UserNotFound($"User not found: {email}"); + } + + if (!user.VerifyPassword(password)) + { + return new LoginResult.WrongPassword("Wrong password"); + } + + var token = _jwtService.GenerateJwtToken(user); + + return new LoginResult.Success(token, user); + } +} diff --git a/Backend/Blog.Core/Domain/Entities/Post.cs b/Backend/Blog.Core/Domain/Entities/Post.cs new file mode 100644 index 0000000..472444a --- /dev/null +++ b/Backend/Blog.Core/Domain/Entities/Post.cs @@ -0,0 +1,76 @@ +namespace Blog.Core.Domain.Entities; + +public class Post +{ + public Guid Id { get; private set; } + public string Name { get; private set; } + public string Description { get; private set; } + public string MarkdownContent { get; private set; } + public DateTime Created { get; private set; } + public Guid AuthorId { get; private set; } + public User? Author { get; private set; } + + // EF Core constructor + private Post() + { + Name = null!; + Description = null!; + MarkdownContent = null!; + } + + private Post(Guid id, string name, string description, string markdownContent, Guid authorId, DateTime created) + { + Id = id; + Name = name; + Description = description; + MarkdownContent = markdownContent; + AuthorId = authorId; + Created = created; + } + + public static Post Create(string name, string description, string markdownContent, Guid authorId) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Name cannot be empty", nameof(name)); + + if (string.IsNullOrWhiteSpace(description)) + throw new ArgumentException("Description cannot be empty", nameof(description)); + + if (string.IsNullOrWhiteSpace(markdownContent)) + throw new ArgumentException("Markdown content cannot be empty", nameof(markdownContent)); + + if (authorId == Guid.Empty) + throw new ArgumentException("Author ID cannot be empty", nameof(authorId)); + + return new Post(Guid.NewGuid(), name, description, markdownContent, authorId, DateTime.UtcNow); + } + + public static Post Restore(Guid id, string name, string description, string markdownContent, Guid authorId, DateTime created) + { + return new Post(id, name, description, markdownContent, authorId, created); + } + + public void Update(string? name, string? description, string? markdownContent) + { + if (name != null) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Name cannot be empty", nameof(name)); + Name = name; + } + + if (description != null) + { + if (string.IsNullOrWhiteSpace(description)) + throw new ArgumentException("Description cannot be empty", nameof(description)); + Description = description; + } + + if (markdownContent != null) + { + if (string.IsNullOrWhiteSpace(markdownContent)) + throw new ArgumentException("Markdown content cannot be empty", nameof(markdownContent)); + MarkdownContent = markdownContent; + } + } +} diff --git a/Backend/Blog.Core/Domain/Entities/User.cs b/Backend/Blog.Core/Domain/Entities/User.cs new file mode 100644 index 0000000..4c303fc --- /dev/null +++ b/Backend/Blog.Core/Domain/Entities/User.cs @@ -0,0 +1,76 @@ +using Blog.Core.Domain.ValueObjects; + +namespace Blog.Core.Domain.Entities; + +public class User +{ + public Guid Id { get; private set; } + public Email Email { get; private set; } + public string Name { get; private set; } + public HashedPassword PasswordHash { get; private set; } + public ICollection Permissions { get; private set; } + + // EF Core constructor + private User() + { + Email = null!; + Name = null!; + PasswordHash = null!; + Permissions = new List(); + } + + private User(Guid id, Email email, string name, HashedPassword passwordHash) + { + Id = id; + Email = email; + Name = name; + PasswordHash = passwordHash; + Permissions = new List(); + } + + public static User Create(string name, string email, string password) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Name cannot be empty", nameof(name)); + + var userEmail = Email.Create(email); + var hashedPassword = HashedPassword.Create(password); + + return new User(Guid.NewGuid(), userEmail, name, hashedPassword); + } + + public static User Restore(Guid id, Email email, string name, HashedPassword passwordHash, ICollection permissions) + { + var user = new User(id, email, name, passwordHash) + { + Permissions = permissions + }; + return user; + } + + public bool VerifyPassword(string password) + { + return PasswordHash.Verify(password); + } + + public void AddPermission(string permission) + { + if (string.IsNullOrWhiteSpace(permission)) + throw new ArgumentException("Permission cannot be empty", nameof(permission)); + + if (!Permissions.Contains(permission)) + { + Permissions.Add(permission); + } + } + + public void RemovePermission(string permission) + { + Permissions.Remove(permission); + } + + public bool HasPermission(string permission) + { + return Permissions.Contains(permission); + } +} diff --git a/Backend/Blog.Core/Domain/Permissions.cs b/Backend/Blog.Core/Domain/Permissions.cs new file mode 100644 index 0000000..6874000 --- /dev/null +++ b/Backend/Blog.Core/Domain/Permissions.cs @@ -0,0 +1,8 @@ +namespace Blog.Core.Domain; + +public static class Permissions +{ + public const string Create = nameof(Create); + public const string Update = nameof(Update); + public const string Delete = nameof(Delete); +} diff --git a/Backend/Blog.Core/Domain/Repositories/IPostRepository.cs b/Backend/Blog.Core/Domain/Repositories/IPostRepository.cs new file mode 100644 index 0000000..19f3bf0 --- /dev/null +++ b/Backend/Blog.Core/Domain/Repositories/IPostRepository.cs @@ -0,0 +1,13 @@ +using Blog.Core.Domain.Entities; + +namespace Blog.Core.Domain.Repositories; + +public interface IPostRepository +{ + Task CreateAsync(Post post); + Task GetAllAsync(); + Task GetByIdAsync(Guid id); + Task GetByUserIdAsync(Guid userId); + Task DeleteByIdAsync(Guid id); + Task UpdateAsync(Post post); +} diff --git a/Backend/Blog.Core/Domain/Repositories/IUserRepository.cs b/Backend/Blog.Core/Domain/Repositories/IUserRepository.cs new file mode 100644 index 0000000..90d9d19 --- /dev/null +++ b/Backend/Blog.Core/Domain/Repositories/IUserRepository.cs @@ -0,0 +1,11 @@ +using Blog.Core.Domain.Entities; + +namespace Blog.Core.Domain.Repositories; + +public interface IUserRepository +{ + Task AddAsync(User user); + Task FindByIdAsync(Guid id); + Task FindByEmailAsync(string email); + Task?> FindPermissionsById(Guid id); +} diff --git a/Backend/Blog.Core/Domain/ValueObjects/Email.cs b/Backend/Blog.Core/Domain/ValueObjects/Email.cs new file mode 100644 index 0000000..57da7a5 --- /dev/null +++ b/Backend/Blog.Core/Domain/ValueObjects/Email.cs @@ -0,0 +1,30 @@ +using System.Text.RegularExpressions; + +namespace Blog.Core.Domain.ValueObjects; + +public record Email +{ + private static readonly Regex EmailRegex = new( + @"^[^@\s]+@[^@\s]+\.[^@\s]+$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public string Value { get; } + + private Email(string value) + { + Value = value; + } + + public static Email Create(string email) + { + if (string.IsNullOrWhiteSpace(email)) + throw new ArgumentException("Email cannot be empty", nameof(email)); + + if (!EmailRegex.IsMatch(email)) + throw new ArgumentException("Invalid email format", nameof(email)); + + return new Email(email.ToLowerInvariant()); + } + + public override string ToString() => Value; +} diff --git a/Backend/Blog.Core/Domain/ValueObjects/HashedPassword.cs b/Backend/Blog.Core/Domain/ValueObjects/HashedPassword.cs new file mode 100644 index 0000000..54c3265 --- /dev/null +++ b/Backend/Blog.Core/Domain/ValueObjects/HashedPassword.cs @@ -0,0 +1,38 @@ +namespace Blog.Core.Domain.ValueObjects; + +public record HashedPassword +{ + public string Value { get; } + + private HashedPassword(string value) + { + Value = value; + } + + public static HashedPassword Create(string plainPassword) + { + if (string.IsNullOrWhiteSpace(plainPassword)) + throw new ArgumentException("Password cannot be empty", nameof(plainPassword)); + + if (plainPassword.Length < 6) + throw new ArgumentException("Password must be at least 6 characters", nameof(plainPassword)); + + var hash = BCrypt.Net.BCrypt.HashPassword(plainPassword); + return new HashedPassword(hash); + } + + public static HashedPassword FromHash(string hash) + { + if (string.IsNullOrWhiteSpace(hash)) + throw new ArgumentException("Hash cannot be empty", nameof(hash)); + + return new HashedPassword(hash); + } + + public bool Verify(string plainPassword) + { + return BCrypt.Net.BCrypt.Verify(plainPassword, Value); + } + + public override string ToString() => Value; +} diff --git a/Backend/Blog.IO/Db/BlogDbContext.cs b/Backend/Blog.IO/Db/BlogDbContext.cs index 5242684..de8405c 100644 --- a/Backend/Blog.IO/Db/BlogDbContext.cs +++ b/Backend/Blog.IO/Db/BlogDbContext.cs @@ -1,4 +1,5 @@ -using Blog.Core.Entities; +using Blog.Core.Domain.Entities; +using Blog.Core.Domain.ValueObjects; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Blog.IO.Db; @@ -21,6 +22,19 @@ public class UserConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.HasKey(user => user.Id); + + builder.Property(user => user.Email) + .HasConversion( + email => email.Value, + value => Email.Create(value)) + .HasColumnName("Email"); + + builder.Property(user => user.PasswordHash) + .HasConversion( + hash => hash.Value, + value => HashedPassword.FromHash(value)) + .HasColumnName("PasswordHash"); + builder.Property(user => user.Permissions) .HasConversion( v => string.Join(',', v), diff --git a/Backend/Blog.IO/Repositories/DbPostRepository.cs b/Backend/Blog.IO/Repositories/DbPostRepository.cs index 6017eb1..6e3feb0 100644 --- a/Backend/Blog.IO/Repositories/DbPostRepository.cs +++ b/Backend/Blog.IO/Repositories/DbPostRepository.cs @@ -1,4 +1,5 @@ -using Blog.Core.UseCases; +using Blog.Core.Domain.Repositories; +using Blog.Core.Domain.Entities; using Blog.IO.Db; using Microsoft.EntityFrameworkCore; @@ -6,28 +7,28 @@ namespace Blog.IO.Repositories; public class DbPostRepository(BlogDbContext context) : IPostRepository { - public async Task CreateAsync(Core.Entities.Post post) + public async Task CreateAsync(Post post) { context.Posts.Add(post); await context.SaveChangesAsync(); return post; } - public async Task GetAllAsync() + public async Task GetAllAsync() { return await context.Posts .Include(blog => blog.Author) .ToArrayAsync(); } - public async Task GetByIdAsync(Guid id) + public async Task GetByIdAsync(Guid id) { return await context.Posts .Include(blog => blog.Author) .FirstOrDefaultAsync(blog => blog.Id == id); } - public async Task GetByUserIdAsync(Guid userId) + public async Task GetByUserIdAsync(Guid userId) { return await context.Posts. Include(blog => blog.Author) @@ -37,7 +38,7 @@ public class DbPostRepository(BlogDbContext context) : IPostRepository public async Task DeleteByIdAsync(Guid id) { - var blog = await context.FindAsync(id); + var blog = await context.FindAsync(id); if (blog == null) { @@ -49,31 +50,10 @@ public async Task DeleteByIdAsync(Guid id) return true; } - public async Task UpdateAsync(Guid id, string? name, string? description, string? markdownContent) + public async Task UpdateAsync(Post post) { - var blog = context.Posts.FirstOrDefault(blog => blog.Id == id); - - if (blog == null) - { - return null; - } - - if (name != null) - { - blog.Name = name; - } - - if (description != null) - { - blog.Description = description; - } - - if (markdownContent != null) - { - blog.MarkdownContent = markdownContent; - } - + context.Posts.Update(post); await context.SaveChangesAsync(); - return blog; + return post; } } diff --git a/Backend/Blog.IO/Repositories/DbUserRepository.cs b/Backend/Blog.IO/Repositories/DbUserRepository.cs index 619acd4..d6f1561 100644 --- a/Backend/Blog.IO/Repositories/DbUserRepository.cs +++ b/Backend/Blog.IO/Repositories/DbUserRepository.cs @@ -1,5 +1,5 @@ -using Blog.Core.Entities; -using Blog.Core.UseCases; +using Blog.Core.Domain.Entities; +using Blog.Core.Domain.Repositories; using Blog.IO.Db; using Microsoft.EntityFrameworkCore; @@ -27,7 +27,7 @@ public class DbUserRepository(BlogDbContext context) : IUserRepository public async Task FindByEmailAsync(string email) { - return await context.Users.FirstOrDefaultAsync(user => user.Email == email); + return await context.Users.FirstOrDefaultAsync(user => user.Email.Value == email); } public async Task?> FindPermissionsById(Guid id) diff --git a/Backend/Blog.Web/Autherization/PermissionRequirementsHandler.cs b/Backend/Blog.Web/Autherization/PermissionRequirementsHandler.cs index 7c46e4d..d5433c7 100644 --- a/Backend/Blog.Web/Autherization/PermissionRequirementsHandler.cs +++ b/Backend/Blog.Web/Autherization/PermissionRequirementsHandler.cs @@ -1,5 +1,5 @@ using System.IdentityModel.Tokens.Jwt; -using Blog.Core.UseCases; +using Blog.Core.Domain.Repositories; using Microsoft.AspNetCore.Authorization; namespace Blog.Web.Autherization; diff --git a/Backend/Blog.Web/Controllers/AuthController.cs b/Backend/Blog.Web/Controllers/AuthController.cs index 394e506..d68436d 100644 --- a/Backend/Blog.Web/Controllers/AuthController.cs +++ b/Backend/Blog.Web/Controllers/AuthController.cs @@ -1,12 +1,6 @@ using Blog.Web.Dtos; using Microsoft.AspNetCore.Mvc; -using Microsoft.IdentityModel.Tokens; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; -using Blog.Core.Entities; -using Blog.Core.Services; -using Blog.Core.UseCases; +using Blog.Core.Application.Services; namespace Blog.Web.Controllers; @@ -27,7 +21,7 @@ public async Task Register(RegisterDto dto) return result switch { - RegisterResult.Success success => Ok(new {success.User.Id, success.User.Name, success.User.Email}), + RegisterResult.Success success => Ok(new {success.User.Id, success.User.Name, Email = success.User.Email.Value}), RegisterResult.UserAlreadyExists userAlreadyExists => Conflict(new { message = userAlreadyExists.Message }), RegisterResult.UserRepositoryError userRepositoryError => BadRequest(new { message = userRepositoryError.Message }), _ => BadRequest() @@ -41,7 +35,7 @@ public async Task Login(LoginDto dto) return result switch { - LoginResult.Success success=> Ok(new { success.Token, user = new {success.User.Id, success.User.Name, success.User.Email}}), + LoginResult.Success success=> Ok(new { success.Token, user = new {success.User.Id, success.User.Name, Email = success.User.Email.Value}}), LoginResult.UserNotFound userNotFound => NotFound(new { message = userNotFound.Message }), LoginResult.WrongPassword wrongPassword => Unauthorized(new { message = wrongPassword.Message }), _ => BadRequest() diff --git a/Backend/Blog.Web/Controllers/PostsController.cs b/Backend/Blog.Web/Controllers/PostsController.cs index e38578f..67cf314 100644 --- a/Backend/Blog.Web/Controllers/PostsController.cs +++ b/Backend/Blog.Web/Controllers/PostsController.cs @@ -1,5 +1,6 @@ -using Blog.Core.Entities; -using Blog.Core.UseCases; +using Blog.Core.Domain.Entities; +using Blog.Core.Domain.Repositories; +using Blog.Core.Domain; using Blog.Web.Dtos; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -21,24 +22,23 @@ public PostsController(IPostRepository repository) [Authorize(Policy = Permissions.Create)] public async Task Create([FromBody] BlogCreateDto dto) { - var blog = new Core.Entities.Post + try { - Id = Guid.NewGuid(), - Name = dto.Name, - Description = dto.Description, - MarkdownContent = dto.MarkdownContent, - Created = DateTime.UtcNow, - AuthorId = dto.AuthorId, - }; - - var created = await _repository.CreateAsync(blog); + var blog = Post.Create(dto.Name, dto.Description, dto.MarkdownContent, dto.AuthorId); + + var created = await _repository.CreateAsync(blog); - if (created == null) + if (created == null) + { + return NotFound(); + } + + return Ok(blog); + } + catch (ArgumentException ex) { - return NotFound(); + return BadRequest(new { message = ex.Message }); } - - return Ok(blog); } [HttpGet] @@ -62,14 +62,23 @@ public async Task GetById(Guid id) [Authorize(Policy = Permissions.Update)] public async Task UpdateById(Guid id, [FromBody] BlogUpdateDto dto) { - var blog = await _repository.UpdateAsync(id, dto.Name, dto.Description, dto.MarkdownContent); + var blog = await _repository.GetByIdAsync(id); if (blog == null) { return NotFound(); } - return Ok(blog); + try + { + blog.Update(dto.Name, dto.Description, dto.MarkdownContent); + await _repository.UpdateAsync(blog); + return Ok(blog); + } + catch (ArgumentException ex) + { + return BadRequest(new { message = ex.Message }); + } } [HttpDelete("{id:guid}")] diff --git a/Backend/Blog.Web/Program.cs b/Backend/Blog.Web/Program.cs index a1e4704..b238631 100644 --- a/Backend/Blog.Web/Program.cs +++ b/Backend/Blog.Web/Program.cs @@ -1,10 +1,10 @@ using System.Diagnostics; using System.IdentityModel.Tokens.Jwt; using System.Text; -using Blog.Core.Entities; +using Blog.Core.Domain; using Blog.Core.Helpers; -using Blog.Core.Services; -using Blog.Core.UseCases; +using Blog.Core.Application.Services; +using Blog.Core.Domain.Repositories; using Blog.IO.Db; using Blog.IO.Extensions; using Blog.IO.Repositories; From d0007e702784900bf2f31e7b1302179e3eb169e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 14:38:23 +0000 Subject: [PATCH 3/5] Remove old non-DDD folders from Blog.Core Co-authored-by: xirzo <43218935+xirzo@users.noreply.github.com> --- Backend/Blog.Core/Entities/Permissions.cs | 15 ---- Backend/Blog.Core/Entities/Post.cs | 12 --- Backend/Blog.Core/Entities/User.cs | 9 --- Backend/Blog.Core/Services/JwtService.cs | 40 ---------- Backend/Blog.Core/Services/UserService.cs | 78 ------------------- Backend/Blog.Core/UseCases/IPostRepository.cs | 11 --- Backend/Blog.Core/UseCases/IUserRepository.cs | 11 --- 7 files changed, 176 deletions(-) delete mode 100644 Backend/Blog.Core/Entities/Permissions.cs delete mode 100644 Backend/Blog.Core/Entities/Post.cs delete mode 100644 Backend/Blog.Core/Entities/User.cs delete mode 100644 Backend/Blog.Core/Services/JwtService.cs delete mode 100644 Backend/Blog.Core/Services/UserService.cs delete mode 100644 Backend/Blog.Core/UseCases/IPostRepository.cs delete mode 100644 Backend/Blog.Core/UseCases/IUserRepository.cs diff --git a/Backend/Blog.Core/Entities/Permissions.cs b/Backend/Blog.Core/Entities/Permissions.cs deleted file mode 100644 index 6818996..0000000 --- a/Backend/Blog.Core/Entities/Permissions.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Blog.Core.Entities; - - -/* Test permissions, all of these - need to be registered inside Program.cs, - then added to individual user inside db. - In order to allow access for this role, add - attribute with policy inside the controller -*/ -public static class Permissions -{ - public const string Create = nameof(Create); - public const string Update = nameof(Update); - public const string Delete = nameof(Delete); -} \ No newline at end of file diff --git a/Backend/Blog.Core/Entities/Post.cs b/Backend/Blog.Core/Entities/Post.cs deleted file mode 100644 index ffe57d6..0000000 --- a/Backend/Blog.Core/Entities/Post.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Blog.Core.Entities; - -public class Post -{ - public Guid Id { get; set; } - public required string Name { get; set; } - public required string Description { get; set; } - public required string MarkdownContent { get; set; } - public DateTime Created { get; set; } - public Guid AuthorId { get; set; } - public User? Author { get; set; } -} diff --git a/Backend/Blog.Core/Entities/User.cs b/Backend/Blog.Core/Entities/User.cs deleted file mode 100644 index ce65718..0000000 --- a/Backend/Blog.Core/Entities/User.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Blog.Core.Entities; -public class User -{ - public Guid Id { get; set; } - public required string Email { get; set; } - public required string Name { get; set; } - public required string PasswordHash { get; set; } - public ICollection Permissions { get; set; } = []; -} diff --git a/Backend/Blog.Core/Services/JwtService.cs b/Backend/Blog.Core/Services/JwtService.cs deleted file mode 100644 index 2c5f474..0000000 --- a/Backend/Blog.Core/Services/JwtService.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; -using Blog.Core.Entities; -using Blog.Core.Helpers; -using Microsoft.IdentityModel.Tokens; - -namespace Blog.Core.Services; - -public class JwtService -{ - public string GenerateJwtToken(User user) - { - var claims = new[] - { - new Claim("guid", user.Id.ToString()), - new Claim(JwtRegisteredClaimNames.Email, user.Email), - new Claim("name", user.Name), - }; - var jwtKey = EnvironmentHelper.GetEnvironmentVariableOrFile("JWT_KEY"); - - if (jwtKey == null) - { - throw new InvalidOperationException("JWT_KEY environment variable is not set."); - } - - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); - var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); - - var token = new JwtSecurityToken( - issuer: EnvironmentHelper.GetEnvironmentVariableOrFile("JWT_ISSUER"), - audience: EnvironmentHelper.GetEnvironmentVariableOrFile("JWT_AUDIENCE"), - claims: claims, - expires: DateTime.UtcNow.AddHours(2), - signingCredentials: credentials - ); - - return new JwtSecurityTokenHandler().WriteToken(token); - } -} \ No newline at end of file diff --git a/Backend/Blog.Core/Services/UserService.cs b/Backend/Blog.Core/Services/UserService.cs deleted file mode 100644 index 7132921..0000000 --- a/Backend/Blog.Core/Services/UserService.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Blog.Core.Entities; -using Blog.Core.UseCases; - -namespace Blog.Core.Services; - -public abstract record RegisterResult -{ - public record Success(User User) : RegisterResult; - public record UserAlreadyExists(string Message) : RegisterResult; - public record UserRepositoryError(string Message) : RegisterResult; -} - -public abstract record LoginResult -{ - public record Success(string Token, User User) : LoginResult; - public record WrongPassword(string Message) : LoginResult; - public record UserNotFound(string Message) : LoginResult; -} - -public class UserService -{ - private readonly IUserRepository _userRepository; - private readonly JwtService _jwtService; - - public UserService(IUserRepository userRepository, JwtService jwtService) - { - _userRepository = userRepository; - _jwtService = jwtService; - } - - public async Task Register(string name, string email, string password) - { - - var existingUser = await _userRepository.FindByEmailAsync(email); - - if (existingUser != null) - { - return new RegisterResult.UserAlreadyExists("User already exists with this email."); - } - - var user = new User - { - Id = Guid.NewGuid(), - Email = email, - Name = name, - PasswordHash = BCrypt.Net.BCrypt.HashPassword(password), - }; - - - var addedUser = await _userRepository.AddAsync(user); - - if (addedUser == null) - { - return new RegisterResult.UserRepositoryError("Failed to add user to the repository."); - } - - return new RegisterResult.Success(addedUser); - } - - public async Task Login(string email, string password) - { - var user = await _userRepository.FindByEmailAsync(email); - - if (user == null) - { - return new LoginResult.UserNotFound($"User not found: {user}"); - } - - if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash)) - { - return new LoginResult.WrongPassword("Wrong password"); - } - - var token = _jwtService.GenerateJwtToken(user); - - return new LoginResult.Success(token, user); - } -} \ No newline at end of file diff --git a/Backend/Blog.Core/UseCases/IPostRepository.cs b/Backend/Blog.Core/UseCases/IPostRepository.cs deleted file mode 100644 index 424cf59..0000000 --- a/Backend/Blog.Core/UseCases/IPostRepository.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Blog.Core.UseCases; - -public interface IPostRepository -{ - Task CreateAsync(Entities.Post post); - Task GetAllAsync(); - Task GetByIdAsync(Guid id); - Task GetByUserIdAsync(Guid userId); - Task DeleteByIdAsync(Guid id); - Task UpdateAsync(Guid id, string? name, string? description, string? markdownContent); -} diff --git a/Backend/Blog.Core/UseCases/IUserRepository.cs b/Backend/Blog.Core/UseCases/IUserRepository.cs deleted file mode 100644 index e1cc0b1..0000000 --- a/Backend/Blog.Core/UseCases/IUserRepository.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Blog.Core.Entities; - -namespace Blog.Core.UseCases; - -public interface IUserRepository -{ - Task AddAsync(User user); - Task FindByIdAsync(Guid id); - Task FindByEmailAsync(string email); - Task?> FindPermissionsById(Guid id); -} \ No newline at end of file From 62572cfc4d9c041e3a4ca222f9c414348d082676 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 14:39:18 +0000 Subject: [PATCH 4/5] Add DDD architecture documentation Co-authored-by: xirzo <43218935+xirzo@users.noreply.github.com> --- Backend/DDD_ARCHITECTURE.md | 123 ++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 Backend/DDD_ARCHITECTURE.md diff --git a/Backend/DDD_ARCHITECTURE.md b/Backend/DDD_ARCHITECTURE.md new file mode 100644 index 0000000..2a3b892 --- /dev/null +++ b/Backend/DDD_ARCHITECTURE.md @@ -0,0 +1,123 @@ +# Domain-Driven Design Architecture + +This backend now follows Domain-Driven Design (DDD) principles with clear separation of concerns. + +## Project Structure + +### Blog.Core (Domain + Application Layer) + +#### Domain Layer (`Blog.Core/Domain`) +Contains the core business logic and domain model: + +**Entities** (`Domain/Entities/`) +- Rich domain entities with behavior and business rules +- `User.cs`: User aggregate with password verification and permission management +- `Post.cs`: Blog post entity with validation and update logic +- Private setters enforce encapsulation +- Factory methods (`Create`, `Restore`) for object construction + +**Value Objects** (`Domain/ValueObjects/`) +- Immutable value types that represent domain concepts +- `Email.cs`: Email address with validation +- `HashedPassword.cs`: Password hashing and verification logic +- Records ensure immutability + +**Repositories** (`Domain/Repositories/`) +- Repository interfaces defining data access contracts +- `IUserRepository.cs`: User data access interface +- `IPostRepository.cs`: Post data access interface +- Defined in domain layer, implemented in infrastructure + +**Domain Constants** (`Domain/`) +- `Permissions.cs`: Permission constants for authorization + +#### Application Layer (`Blog.Core/Application`) +Contains use cases and application services: + +**Services** (`Application/Services/`) +- `UserService.cs`: User registration and login use cases +- `JwtService.cs`: JWT token generation +- Orchestrates domain logic without containing business rules + +### Blog.IO (Infrastructure Layer) +Contains implementation details and external concerns: + +**Database** (`Db/`) +- `BlogDbContext.cs`: Entity Framework Core DbContext with entity configurations +- Value object conversions for persistence + +**Repositories** (`Repositories/`) +- `DbUserRepository.cs`: User repository implementation using EF Core +- `DbPostRepository.cs`: Post repository implementation using EF Core + +**Migrations** +- EF Core database migrations + +### Blog.Web (Presentation Layer) +Contains HTTP API concerns: + +**Controllers** +- `AuthController.cs`: Authentication endpoints +- `PostsController.cs`: Blog post CRUD endpoints +- Thin controllers that delegate to application services + +**DTOs** +- Data Transfer Objects for API contracts + +**Authorization** +- Custom permission-based authorization handlers + +## Key DDD Principles Applied + +### 1. Rich Domain Model +Entities contain business logic and enforce invariants: +```csharp +// Before (Anemic): Properties with public setters +public string Name { get; set; } + +// After (Rich): Private setters with validation +public string Name { get; private set; } +public void Update(string? name, ...) { + if (name != null && string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Name cannot be empty"); + Name = name; +} +``` + +### 2. Value Objects +Encapsulate domain concepts and validation: +```csharp +var email = Email.Create(emailString); // Validates format +var password = HashedPassword.Create(plainPassword); // Hashes automatically +``` + +### 3. Encapsulation +Entity creation through factory methods: +```csharp +var user = User.Create(name, email, password); +var post = Post.Create(name, description, content, authorId); +``` + +### 4. Separation of Concerns +- **Domain**: Business rules and logic +- **Application**: Use cases and workflows +- **Infrastructure**: Technical implementation +- **Presentation**: HTTP/API concerns + +### 5. Dependency Inversion +Repository interfaces defined in domain, implemented in infrastructure + +## Benefits of This Architecture + +1. **Testability**: Domain logic isolated from infrastructure +2. **Maintainability**: Clear boundaries between layers +3. **Flexibility**: Easy to change infrastructure without affecting domain +4. **Business Focus**: Domain model reflects business language +5. **Protection**: Encapsulation prevents invalid states + +## Migration Compatibility + +The new architecture maintains database compatibility through: +- EF Core value conversions for Email and HashedPassword +- Same table structure and column names +- Existing migrations remain valid From 340d6710b004f6483866378b15e4469e9efe246f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 14:40:29 +0000 Subject: [PATCH 5/5] Fix documentation based on code review feedback Co-authored-by: xirzo <43218935+xirzo@users.noreply.github.com> --- Backend/DDD_ARCHITECTURE.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Backend/DDD_ARCHITECTURE.md b/Backend/DDD_ARCHITECTURE.md index 2a3b892..9d262dd 100644 --- a/Backend/DDD_ARCHITECTURE.md +++ b/Backend/DDD_ARCHITECTURE.md @@ -24,8 +24,8 @@ Contains the core business logic and domain model: **Repositories** (`Domain/Repositories/`) - Repository interfaces defining data access contracts -- `IUserRepository.cs`: User data access interface -- `IPostRepository.cs`: Post data access interface +- `IUserRepository`: User data access interface +- `IPostRepository`: Post data access interface - Defined in domain layer, implemented in infrastructure **Domain Constants** (`Domain/`) @@ -77,10 +77,12 @@ public string Name { get; set; } // After (Rich): Private setters with validation public string Name { get; private set; } -public void Update(string? name, ...) { - if (name != null && string.IsNullOrWhiteSpace(name)) - throw new ArgumentException("Name cannot be empty"); - Name = name; +public void Update(string? name /* ... other parameters */) { + if (name != null) { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Name cannot be empty"); + Name = name; + } } ```