Avoiding Identity Obsession in .NET with Entity Framework Core

Published on Tuesday, October 1, 2024

Complicated translation

In a previous post, I introduced Identity Obsession. Identity Obsession can introduce race conditions relating to consistency with Domain Entities and other complexity. In that post, I point out that most entities have an inherent (implicit) local identity and that any additional global Identifiers are due to persistence (RDBMS) implementation details. I recommended that Identifiers be something in your persistence layer rather than your Domain layer. I won't rehash that post more than that here, but you may want to read that first.

It can be perceived as easy for me to say when it comes to design constraints of Aggregates and Entities when implementing the persistence layer. This perception is probably most apparent with technologies like Entity Framework Core (EF) and that Entity Framework's support for Domain-Driven Design has been an afterthought. When dealing with external sources and targets of data, the Data Transfer Object pattern is recommended. Technically, EF supports persistence via DTOs.

Entity Framework can rarely do what it does out-of-the-box, provided classes are designed with a Domain-Driven Design mindset. Impedance Mismatch rears its ugly head resulting in translating data to/from the limited data types that databases tend to have (see HasConversion, HasKey, et al.) Despite best practices that recommend that Entities be implemented as POCOs and relational mapping be configured with fluent builders—outside of the Domain Layer—the needs and constraints of databases can easily leak into the design and evolution of Domain Objects.

In this post, I will provide an example of configuring Entity Framework to work with Domain Objects that don't suffer from Identity Obsession. The scenario (or use-case) I'll show with this Domain Object is onboarding a Client to the point they will have a Domain Identifier assigned. The Domain I introduced in my previous post involved SSNs; we can presume the Social Security Administration would be the Domain context. I'm sure none of my readers work in this context, but I believe it to be understandable by many people, making it a good example of Domain Identity. For my Canadian friends, replace "SSN" with "SIN" and "Social Security Administration" with "Service Canada."

Entity Framework is a persistence/infrastructure implementation detail, and the Repository Pattern is used to isolate those details from the Domain. I gave an example repository in my previous post; for this example, I've expanded it to support asynchronous and the Result Pattern:

public interface IClientRepository
{
    Task<Result<Client>> FindBySsnAsync(Ssn ssn, CancellationToken cancellationToken);
    Task<Result> SaveAsync(Client client, CancellationToken cancellationToken);
    Task<Result<Ssn>> AddAsync(Client client, CancellationToken cancellationToken);
	Task<Result<IEnumerable<Client>>> FindClientsAsync(CancellationToken cancellationToken);
}

This Repository provides the ability to find a Client by SSN (FindBySsnAsync), save a Client after being updated (SaveAsync), and add a new Client by allocating a new SSN (AddAsync.)

Elements within a container naturally have container Identity. The container understands how to identify individual elements based on an identifier. Contained elements, therefore, do not also have to take on that burden. For example:

public class Client(string givenName, string familyName)
    : Person(givenName, familyName)
{
    public void ChangeName(string givenName, string familyName)
    {
        GivenName = givenName;
        FamilyName = familyName;
    }
}

I've expanded Client to support an update scenario (ChangeName) so that there is Domain behavior.

Quote
"All models are wrong, but some are useful"

The use-case of working with a Domain Object (Client) before needing an SSN to be persisted might follow a sequence like this:

sequence

A Client instance exists for a time before a Domain Identifier is allocated for it. However, our database requires an SSN for each client record. So, we need to configure Entity Framework to accommodate that detail and include a primary key. We do that through an IEntityTypeConfiguration<T> implementation like this:

public class ClientEntityTypeConfiguration : IEntityTypeConfiguration<Client>
{
    public void Configure(EntityTypeBuilder<Client> builder)
    {
        // Create a string "Id" Shadow Property to hold GUID values
        builder.Property<string>(ColumnNames.Id)
            .HasColumnType("varchar(36)")
            .HasMaxLength(36);
        builder.HasKey(ColumnNames.Id);

        // Create a string "Ssn" Shadow Property that uses the type Sss,
        // with conversion
        builder.Property<Ssn>(ColumnNames.Ssn)
            .HasColumnType("varchar(11)")
            .HasMaxLength(11)
            .HasConversion(ssn => ssn.ToString(), value => Ssn.Parse(value))
            .IsRequired();
}

As with any IEntityTypeConfiguration<T> implementation, we're configuring the primary key and other properties with property-specific requirements. This implementation may differ from other implementations because we're configuring shadow properties for our identifiers. The DbContext implementation will be responsible for the persistence concern of generating the primary key value, and the repository will be responsible for the Domain concern of generating the Domain Identifier (SSN).

Let's look at that DbContext implementation (I'm using SQLite for this implementation):

public class DatabaseContext : DbContext
{
	protected override void OnModelCreating(ModelBuilder modelBuilder)
	{
		modelBuilder.ApplyConfiguration(new ClientEntityTypeConfiguration());

		base.OnModelCreating(modelBuilder);
	}

	public async Task<Result<Client>> GetClientBySsnAsync(Ssn ssn, CancellationToken cancellationToken)
	{
		var client = await Clients
			.SingleOrDefaultAsync(c => EF.Property<Ssn>(c, ColumnNames.Ssn) == ssn, cancellationToken);

		return client ?? Result<Client>.NotFound();
	}

	public async Task<Result<Client>> GetClientByIdAsync(Guid id, CancellationToken cancellationToken)
	{
		var client = await Clients
			.SingleOrDefaultAsync(c => EF.Property<string>(c, ColumnNames.Id) == id.ToString(), cancellationToken);

		return client ?? Result<Client>.NotFound();
	}

	public Result<Ssn> GetClientSsn(Client client)
	{
		var entry = Entry(client);
		var currentValue = entry.Property(ColumnNames.Ssn).CurrentValue as Ssn;

		return currentValue ?? Result<Ssn>.NotFound();
	}

	public async Task AddClientAsync(Client client, Ssn ssn, CancellationToken cancellationToken)
	{
		var entry = Entry(client);
		entry.Property(ColumnNames.Ssn).CurrentValue = ssn;
		entry.Property(ColumnNames.Id).CurrentValue = Guid.NewGuid().ToString();
		await AddAsync(client, cancellationToken);

		await SaveChangesAsync(cancellationToken);
	}
	public DbSet<Client> Clients { get; set; }

	protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
	{
		optionsBuilder.UseSqlite($"Data Source={DataSourcePath}");
	}

	public static string DataSourcePath {
		get
		{
			var localAppDataPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
			return Path.Join(localAppDataPath, "clients.db");
		}
	}
}

Notable is AddClientAsync, where it accepts an Ssn value, sets the shadow property value for that database column, and generates the primary key by calling Guid.NewGuid(). DatabaseContext encapsulates the implementation detail of shadow properties. I've chosen to generate aGuid in code rather than by the database for clarity. As a database concern, it could go either way. Also notable is the method GetClientSsn, which shows how an SSN can be obtained outside the Domain (e.g., for Presentation concerns.)

Next, let's look at an implementation of IClientRepository:

public class ClientRepository(DatabaseContext dbContext, ISsnRegistry ssnRegistry) : IClientRepository
{
	private readonly DatabaseContext dbContext = dbContext;
	private readonly ISsnRegistry ssnRegistry = ssnRegistry;

	public Task<Result<IEnumerable<Client>>> FindClientsAsync(CancellationToken _)
	{
		Result<IEnumerable<Client>> result = dbContext.Clients;
		return Task.FromResult(result);
	}

	public Task<Result<Client>> FindBySsnAsync(Ssn ssn, CancellationToken cancellationToken)
	{
		return dbContext.GetClientBySsnAsync(ssn, cancellationToken);
	}

	public async Task<Result<Ssn>> AddAsync(Client client, CancellationToken cancellationToken)
	{
		var reservationResult = ssnRegistry.Reserve();
		if (reservationResult.IsError()) return Result<Ssn>.Error();

		using var ssnReservation = reservationResult.Value;
		Ssn newSsn = new(ssnReservation.Value);
		await dbContext.AddClientAsync(client, newSsn, cancellationToken);
		await dbContext.SaveChangesAsync(cancellationToken);
		ssnReservation.Commit();
		return newSsn;
	}

	public async Task<Result> SaveAsync(Client client, CancellationToken cancellationToken)
	{
		var entry = dbContext.Entry(client);
		switch (entry.State)
		{
			case EntityState.Detached:
				{
					var addResult = await AddAsync(client, cancellationToken);
					return addResult.IsSuccess
						? Result.Success()
						: Result.CriticalError(addResult.Errors.ToArray());
				}
			case EntityState.Modified:
				await dbContext.SaveChangesAsync(cancellationToken);
				return Result.Success();
			default:
				return Result.Success();
		}
	}

	public Result<Ssn> GetClientSsn(Client client)
	{
		return dbContext.GetClientSsn(client);
	}

	public Task<Result<Client>> FindByIdAsync(Guid id, CancellationToken cancellationToken)
	{
		return dbContext.GetClientByIdAsync(id, cancellationToken);
	}
}

Since the primary key and the SSN storage is a Persistence concern, the DbContext implementation is where the logic to work with Shadow Properties lives.

SaveAsync and FindBySsnAsync simply encapsulate DbContext.SaveChangesAsync and DatabaseContext.GetClientBySsnAsync respectively. FindClientsAsync finds all the client instances (typically, this would have a filter, which I've omitted for clarity.) SSN allocation is outside the scope of this post. But, because SSN allocation is a Domain concern, I'll show where it might fit in an orchestration with DatabaseContext within AddAsync (using a fictitious ISsnRegistry object that manages generating/reserving then committing an SSN value--completing/committing the reserved value if it is successfully persisted to the database.) I've included GetClientSsn as an example of how an SSN can be obtained outside the Domain (e.g., for Presentation concerns.) Also, I've included FindByIdAsync as an example of how we can find a Client instance based on a primary key value.

Caveat
Storing SSNs in a database is frowned upon. This example is for clarity, the value should be hashed or encrypted before storing in the database. Look ups based on last-four & name are beyond the scope of this post

Summary

Entity Framework makes mapping Entity properties to columns in a relational database easy. But don't let that ease of use let Persistence concerns leak into your Domain. With a clear understanding of what Domain concerns are, what Persistence concerns are, and what Presentation concerns are Entity Framework can easily be leveraged to support loosely coupling the Persistence concerns from Entities.

The code from this post can be found in GitHub.

comments powered by Disqus