Clean Architecture Issue when using .Net Core Identity (.NET 5)

Viewed 402

I've recently started messing with the Clean Architecture concept while using .NET 5. The problem I am running into is that although Identity is well isolated in the Infrastructure layer, if I want to use additional properties on the user without mixing that data with the AspNetUsers table, the problems start.

I will have to create a new entity in the Domain layer, for example UserProfile, and figure out a way to relate that entity to the ApplicationUser entity without

  1. including an ApplicationUser reference in my new entity
  2. moving the ApplicationUser entity to the Domain layer and reference Identity namespaces in there.

In all Clean Architecture projects this situation is either not addressed (they simply add new properties into ApplicationUser) or the solution given is to include a reference to the Identity User Id in the new entity.

I don't like the first solution since it forces me to add custom fields to an Identity table. The second solution brings new and complex problems since it will force me to manually synchronize 2 tables in case of creation or deletion of data, also forcing the use of transactions to avoid orphan data in case of synchronization failure. It also brings an abstraction issue since deleting a user using only Identity will not take care automatically of unknown entities (from future plugins) that might depend or relate somehow to AspNetUsers table.

Let me be more concrete. Here's my simplified project structure (using .NET core 5):

Application

  • Interface
    • IApplicationDbContext.cs

Core

  • Domain
    • UserProfile.cs (my custom user class with additional properties)

Infrastructure

  • Data
    • ApplicationDbContext.cs (Interface implementation)
  • Identity
    • ApplicationRole.cs
    • ApplicationUser.cs DependencyInjection.cs

Web

my web application

Here's my code:

IApplicationDbContext.cs

namespace Application.Common.Interfaces
{
    public interface IApplicationDbContext
    {        
        Task<int> SaveChangesAsync(CancellationToken cancellationToken);
    }
}

UserProfile.cs

namespace Core.Domain
{
    public class UserProfile : AuditEntity
    {
        // Don't want to reference ApplicationUser in domain.
        // [ForeignKey("Id")]
        //public virtual ApplicationUser User { get; set; }

        [StringLength(50)]
        public string FirstName { get; set; }

        [StringLength(50)]
        public string LastName { get; set; }

        [StringLength(50)]
        public string JobPosition { get; set; }

        [StringLength(50)]
        public string Photo { get; set; }

        [DefaultValue(true)]
        public bool IsListed { get; set; }

        [DefaultValue(false)]
        public bool IsDisabled { get; set; }

        [DefaultValue(false)]
        public bool IsLocked { get; set; }

        public DateTime? LastLoginDate { get; set; }

        public DateTime? LastActivityDate { get; set; }

        [StringLength(100)]
        public string LastSessionId { get; set; }

        [StringLength(256)]
        public string PrivateFolder { get; set; }

        public bool IsOnApproval { get; set; } = true;

        public bool IsDeleted { get; set; } = false;
    }
}

ApplicationDbContext.cs

namespace Infrastructure.Data
{
    
    public partial class ApplicationDbContext : IdentityDbContext<ApplicationUser, ApplicationRole, int>, IApplicationDbContext
    {                        
        public ApplicationDbContext(DbContextOptions options) : base(options)
        {
            
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);


            modelBuilder.ApplyConfiguration(new Configurations.UserConfiguration());                    
            modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());                            
        }
                   
        public override Task<int> SaveChangesAsync(CancellationToken cancellationToken)
        {
            var entries = ChangeTracker.Entries().Where(x => x.Entity is AuditEntity && (x.State == EntityState.Added || x.State == EntityState.Modified));

            foreach (var entry in entries)
            {
                if (entry.State == EntityState.Added)
                {
                    ((AuditEntity)entry.Entity).CreatedBy = _currentUserService.UserId;
                    ((AuditEntity)entry.Entity).CreatedDate = DateTime.UtcNow;
                }
                ((AuditEntity)entry.Entity).LastModifiedBy = _currentUserService.UserId;
                ((AuditEntity)entry.Entity).LastModifiedDate = DateTime.UtcNow;
            }
            return base.SaveChangesAsync(cancellationToken);
        }

    }
}

ApplicationRole.cs

namespace Infrastructure.Identity
{
    
    public class ApplicationRole : IdentityRole<int> //, ISoftDeletable
    {
        public ApplicationRole() : base() { }
        public ApplicationRole(string name) : base(name) { }
    }
}

ApplicationUser.cs

namespace Infrastructure.Identity
{    
    public class ApplicationUser: IdentityUser<int>
    {      
        
    }
}

DependencyInjection.cs

namespace Infrastructure
{
    public static class DependencyInjection
    {
        public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration config)
        {            
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(
                    config.GetConnectionString("eCMConnection"),
                    context => context.MigrationsAssembly(Assembly.GetExecutingAssembly().FullName)));
         
            services.AddIdentity<ApplicationUser, ApplicationRole>(
                    options =>
                    {
                        options.Password.RequireDigit = true;
                        options.Password.RequiredLength = 8;
                        options.Password.RequireNonAlphanumeric = true;
                        options.SignIn.RequireConfirmedAccount = false;
                    }
                )
                .AddRoleManager<RoleManager<ApplicationRole>>()
                .AddEntityFrameworkStores<ApplicationDbContext>()
                .AddDefaultTokenProviders(); // two factor authentication
            
            services.AddScoped<IApplicationDbContext>(provider => provider.GetService<ApplicationDbContext>());

            services.AddTransient<IEmailService, EmailService>();

            return services;
        }
    }
}

So, my question is, what is the best approach to be able to have a custom UserProfile entity with data stored in a table related to AspNetUsers table without having to reference ApplicationUser and Identity in UserProfile domain entity. What is the best approach to be able to create and delete Identity users but also related data from my UserProfile table. As anyone seen an example of this implemented?

Thanks! Hope someone can throw some light into this.

1 Answers

I think first you have to decide whether you finally want to store your additional properties in the same table or in a different table as the Identity entity. As you already pointed out both approaches have advantages and disadvantages but I don't see any other option than to choose one.

Once decided you could still encapsulate original Identity entity and the additional properties in one domain object without creating a dependency to infrastructure layer. One option would be repository pattern where the interface lives on domain layer and implementation on infrastructure layer. In the repository you map the domain object to one or multiple tables. This way you could also encapsulate transaction handling if needed.

Related