Domain Layer — Entities, Value Objects, and Zero External Dependencies
What the Domain layer contains, why it has zero NuGet dependencies, how to design entities with private setters, and the patterns that keep domain logic pure and testable.
What the Domain Layer Is
The Domain layer is the innermost ring. It contains the core business concepts — the things the application is about — expressed as code. It has no external dependencies: no NuGet packages, no database, no HTTP, nothing.
Domain layer contains:
✓ Entities (Patient, Prescription, DrugOrder)
✓ Value objects (PatientId, Dosage, MedicationCode)
✓ Domain events (PatientAdmittedEvent, PrescriptionCreatedEvent)
✓ Domain exceptions (InvalidDosageException)
✓ Interfaces that express domain concepts (IDomainEvent)
✓ Business rules encoded as methods on entities
Domain layer does NOT contain:
✗ Database access (EF Core, Dapper)
✗ HTTP clients, email, queues
✗ Validation frameworks (FluentValidation belongs in Application)
✗ Logging frameworks
✗ DTOs or response objectsThe Domain.csproj File
<!-- src/SystemForge.Domain/SystemForge.Domain.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- No PackageReference entries — zero external dependencies -->
<!-- No ProjectReference entries — innermost layer -->
</Project>Entity Base Class
// Domain/Primitives/Entity.cs
namespace SystemForge.Domain.Primitives;
public abstract class Entity<TId> : IEquatable<Entity<TId>>
where TId : notnull
{
protected Entity(TId id) => Id = id;
protected Entity() { } // EF Core needs a parameterless constructor
public TId Id { get; protected set; } = default!;
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyList<IDomainEvent> PopDomainEvents()
{
var events = _domainEvents.ToList();
_domainEvents.Clear();
return events;
}
protected void RaiseDomainEvent(IDomainEvent domainEvent)
=> _domainEvents.Add(domainEvent);
// Value equality by Id — two entities with the same Id are the same entity
public bool Equals(Entity<TId>? other) =>
other is not null && EqualityComparer<TId>.Default.Equals(Id, other.Id);
public override bool Equals(object? obj) => Equals(obj as Entity<TId>);
public override int GetHashCode() => EqualityComparer<TId>.Default.GetHashCode(Id);
public static bool operator ==(Entity<TId>? left, Entity<TId>? right) =>
left?.Equals(right) ?? right is null;
public static bool operator !=(Entity<TId>? left, Entity<TId>? right) =>
!(left == right);
}A Domain Entity
// Domain/Entities/Patient.cs
namespace SystemForge.Domain.Entities;
public sealed class Patient : Entity<PatientId>
{
public string Name { get; private set; } = string.Empty;
public DateOnly DateOfBirth { get; private set; }
public string MRN { get; private set; } = string.Empty; // Medical Record Number
public bool IsActive { get; private set; } = true;
private readonly List<Prescription> _prescriptions = [];
public IReadOnlyList<Prescription> Prescriptions => _prescriptions.AsReadOnly();
private Patient() { } // EF Core
// Factory method — the only way to create a valid Patient
public static Patient Create(string name, DateOnly dateOfBirth, string mrn)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(mrn);
var patient = new Patient
{
Id = PatientId.New(),
Name = name,
DateOfBirth = dateOfBirth,
MRN = mrn,
};
patient.RaiseDomainEvent(new PatientRegisteredDomainEvent(patient.Id, patient.MRN));
return patient;
}
// Business rule encoded as a method
public Result AddPrescription(Prescription prescription)
{
if (!IsActive)
return Result.Failure(PatientErrors.InactivePatient);
if (_prescriptions.Any(p => p.MedicationCode == prescription.MedicationCode && p.IsActive))
return Result.Failure(PatientErrors.DuplicateActivePrescription);
_prescriptions.Add(prescription);
RaiseDomainEvent(new PrescriptionAddedDomainEvent(Id, prescription.Id));
return Result.Success();
}
public void Deactivate()
{
IsActive = false;
RaiseDomainEvent(new PatientDeactivatedDomainEvent(Id));
}
}Domain Errors
// Domain/Errors/PatientErrors.cs
namespace SystemForge.Domain.Errors;
public static class PatientErrors
{
public static readonly Error NotFound =
new("Patient.NotFound", "Patient with the given ID was not found.");
public static readonly Error InactivePatient =
new("Patient.Inactive", "Cannot add a prescription to an inactive patient.");
public static readonly Error DuplicateActivePrescription =
new("Patient.DuplicatePrescription",
"An active prescription for this medication already exists.");
}
// Domain/Primitives/Error.cs
public sealed record Error(string Code, string Description)
{
public static readonly Error None = new(string.Empty, string.Empty);
}Domain Interfaces
Domain interfaces express what the domain needs from the outside world, without specifying how it gets it:
// Domain/Abstractions/IDomainEvent.cs
namespace SystemForge.Domain.Abstractions;
public interface IDomainEvent { }
// Domain/Abstractions/IPatientRepository.cs
// NOTE: repository interfaces can live in Domain or Application — here they live in Application
// because they describe a use case, not a pure domain concept
// Use your judgment based on how closely tied the interface is to domain languageWhat Should NOT Be in the Domain Layer
// ✗ WRONG: EF Core in Domain
using Microsoft.EntityFrameworkCore;
public sealed class Patient
{
[Key] // ← EF Core attribute — belongs in Infrastructure config
public Guid Id { get; set; }
[MaxLength(200)] // ← EF Core attribute
public string Name { get; set; } = string.Empty;
}
// ✓ CORRECT: clean entity with configuration in Infrastructure
public sealed class Patient : Entity<PatientId>
{
public string Name { get; private set; } = string.Empty;
// EF Core mapping goes in Infrastructure/Persistence/Configurations/PatientConfiguration.cs
}
// Infrastructure/Persistence/Configurations/PatientConfiguration.cs
public sealed class PatientConfiguration : IEntityTypeConfiguration<Patient>
{
public void Configure(EntityTypeBuilder<Patient> builder)
{
builder.HasKey(p => p.Id);
builder.Property(p => p.Id).HasConversion(id => id.Value, v => new PatientId(v));
builder.Property(p => p.Name).HasMaxLength(200).IsRequired();
}
}Key Takeaway
The Domain layer's power comes from what it refuses to know. An entity with no EF Core attributes, no logging, and no HTTP client can be instantiated in a unit test with a single
newcall and no setup. Every external dependency you remove from the domain is a test you can write without a container, a database, or a network.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.