Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
Clean Architecture.NETDomain LayerEntitiesDomain Design
Share:𝕏

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 objects

The Domain.csproj File

XML
<!-- 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

C#
// 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

C#
// 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

C#
// 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:

C#
// 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 language

What Should NOT Be in the Domain Layer

C#
// ✗ 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 new call 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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.