DEV Community

Cover image for Clean Architecture in .NET 10: The Domain Layer — Entities That Actually Have Behavior
Brian Spann
Brian Spann

Posted on

Clean Architecture in .NET 10: The Domain Layer — Entities That Actually Have Behavior

In Part 1, we set up our solution structure and created our first entity with real behavior. Now we'll flesh out the Domain layer properly—value objects, the Collection aggregate, and honest talk about when your "domain" is too thin to bother.


The Domain Layer's Job

The Domain layer has one job: encode the business rules that would exist even if there were no computers.

  • "A prompt must have a title" — business rule
  • "Updating a prompt creates a version" — business rule
  • "Tags are case-insensitive" — business rule
  • "The database column is NVARCHAR(500)" — not a business rule

If you find yourself thinking about HTTP status codes, SQL queries, or JSON serialization in your Domain project, you've gone off track.


Value Objects: Wrapping Primitives That Deserve It

The typical Clean Architecture tutorial tells you to wrap everything:

// This is often shown, but...
public record Email(string Value);
public record FirstName(string Value);
public record LastName(string Value);
Enter fullscreen mode Exit fullscreen mode

🔥 Real talk: Most of the time, this is pointless ceremony. You've created a wrapper that does nothing but add .Value everywhere.

Value objects earn their keep when they have behavior or validation:

src/PromptVault.Domain/ValueObjects/Tag.cs

namespace PromptVault.Domain.ValueObjects;

public readonly record struct Tag
{
    public string Value { get; }
    public string Slug { get; }

    public Tag(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
            throw new ArgumentException("Tag cannot be empty", nameof(value));

        if (value.Length > 50)
            throw new ArgumentException("Tag cannot exceed 50 characters", nameof(value));

        Value = value.Trim();
        Slug = Slugify(Value);
    }

    private static string Slugify(string input)
    {
        return input
            .ToLowerInvariant()
            .Replace(" ", "-")
            .Replace("_", "-");
    }

    public static implicit operator string(Tag tag) => tag.Value;

    public override string ToString() => Value;
}
Enter fullscreen mode Exit fullscreen mode

Why this value object earns its place:

  • Validation: can't create empty or too-long tags
  • Behavior: auto-generates a slug
  • Implicit conversion: can use it where strings are expected

But don't do this:

// ❌ Pointless wrapper — just use string
public readonly record struct PromptTitle(string Value);
Enter fullscreen mode Exit fullscreen mode

Unless PromptTitle has validation or behavior beyond "is not null," it's noise.


ModelType: When Enums Aren't Enough

For ModelType (gpt-4, claude-3, etc.), we have options:

Option 1: Just use a string

public string ModelType { get; private set; }
Enter fullscreen mode Exit fullscreen mode

Simple. Works. But any string is accepted.

Option 2: Enum

public enum ModelType { Gpt4, Gpt4Turbo, Claude3Opus }
Enter fullscreen mode Exit fullscreen mode

Type-safe, but adding new models requires code changes and redeployment.

Option 3: Smart Value Object

public readonly record struct ModelType
{
    public string Value { get; }
    public string Provider { get; }

    // Well-known models
    public static readonly ModelType Gpt4 = new("gpt-4", "openai");
    public static readonly ModelType Claude3Opus = new("claude-3-opus", "anthropic");

    public ModelType(string value, string? provider = null)
    {
        if (string.IsNullOrWhiteSpace(value))
            throw new ArgumentException("Model type is required", nameof(value));

        Value = value.Trim().ToLowerInvariant();
        Provider = provider?.ToLowerInvariant() ?? InferProvider(Value);
    }

    private static string InferProvider(string model)
    {
        if (model.StartsWith("gpt")) return "openai";
        if (model.StartsWith("claude")) return "anthropic";
        if (model.StartsWith("gemini")) return "google";
        return "unknown";
    }
}
Enter fullscreen mode Exit fullscreen mode

Tradeoff acknowledged: This is more code than an enum. It's worth it if:

  • Users can enter custom model names
  • You want to infer provider from model name
  • New models shouldn't require code changes

For most apps, an enum is fine. Pick based on your needs, not architectural purity.


The Collection Entity

Let's add our second aggregate root: Collections (like folders for prompts).

namespace PromptVault.Domain.Entities;

public class Collection
{
    public Guid Id { get; private set; }
    public string Name { get; private set; } = null!;
    public string? Description { get; private set; }
    public bool IsPublic { get; private set; }
    public Guid OwnerId { get; private set; }
    public DateTime CreatedAt { get; private set; }

    private readonly List<Guid> _promptIds = new();
    public IReadOnlyCollection<Guid> PromptIds => _promptIds.AsReadOnly();

    private Collection() { }

    public Collection(string name, Guid ownerId, string? description = null)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw new ArgumentException("Name is required", nameof(name));

        Id = Guid.NewGuid();
        Name = name.Trim();
        Description = description?.Trim();
        OwnerId = ownerId;
        CreatedAt = DateTime.UtcNow;
    }

    public void AddPrompt(Guid promptId)
    {
        if (!_promptIds.Contains(promptId))
            _promptIds.Add(promptId);
    }

    public void RemovePrompt(Guid promptId)
    {
        _promptIds.Remove(promptId);
    }

    public void MakePublic() => IsPublic = true;
    public void MakePrivate() => IsPublic = false;
}
Enter fullscreen mode Exit fullscreen mode

Design Decision: IDs vs Navigation Properties

Notice we store List<Guid> _promptIds rather than List<Prompt> _prompts.

The purist approach: Store full Prompt entities.

The pragmatic approach: Store IDs. Why?

  • A prompt can be in multiple collections (many-to-many)
  • Loading a collection doesn't load all prompts
  • Avoids deep object graphs

⚠️ Real-world callout: If you store full entities, EF Core will try to load them all when you load a Collection. For a collection with 500 prompts, that's a problem.

The tradeoff: your domain doesn't enforce that those IDs actually exist. That's an infrastructure concern (foreign keys) or application concern (validation before adding).


The Anemic Domain: When It's Okay

Here's the uncomfortable truth: some domains really are just CRUD.

If your application is:

  • Accept data from user
  • Validate it
  • Store it
  • Retrieve it
  • Display it

...then your "domain logic" is basically just validation. That's fine. Don't invent complexity.

Signs Your Domain Is Actually Anemic

  1. Every entity method is just a setter in disguise:
   public void UpdateTitle(string title) => Title = title;
Enter fullscreen mode Exit fullscreen mode
  1. All your "business rules" are just null checks and length limits

  2. Your handlers do all the work; entities are just data bags

What To Do About It

Option A: Embrace it. Use EF Core entities directly. Skip the ceremony.

Option B: Keep the structure but be minimal. Thin Domain layer. Most logic in Application layer services.

Option C: Look harder for the domain. Often there IS logic hiding in:

  • "Only admins can delete"
  • "Can't change status from Published back to Draft"
  • "Title must be unique per user"

Where Does Validation Live?

Validation Type Where It Lives Example
Domain invariants Entity constructors/methods "Prompt must have content"
Business rules Domain entities "Updating content creates a version"
Input validation Application layer (FluentValidation) "Email must be valid format"
API contracts API layer "Field X is required in this request"

Overlap is okay. Checking "title is not empty" in both the API request validator AND the entity constructor is fine. Defense in depth.


Updated Prompt with Value Objects

Here's our refined Prompt entity using the Tag value object:

using PromptVault.Domain.ValueObjects;

namespace PromptVault.Domain.Entities;

public class Prompt
{
    public Guid Id { get; private set; }
    public string Title { get; private set; } = null!;
    public string Content { get; private set; } = null!;
    public ModelType ModelType { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public DateTime? UpdatedAt { get; private set; }

    private readonly List<PromptVersion> _versions = new();
    public IReadOnlyCollection<PromptVersion> Versions => _versions.AsReadOnly();

    private readonly List<Tag> _tags = new();
    public IReadOnlyCollection<Tag> Tags => _tags.AsReadOnly();

    private Prompt() { }

    public Prompt(string title, string content, ModelType modelType)
    {
        // Validation...
        Id = Guid.NewGuid();
        Title = title.Trim();
        Content = content;
        ModelType = modelType;
        CreatedAt = DateTime.UtcNow;
        _versions.Add(new PromptVersion(this, 1, content));
    }

    public void AddTag(string tagValue)
    {
        var tag = new Tag(tagValue); // Validation happens here

        if (_tags.Any(t => t.Slug == tag.Slug))
            return; // Deduplicate by slug

        _tags.Add(tag);
        UpdatedAt = DateTime.UtcNow;
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing Domain Logic

public class PromptTests
{
    [Fact]
    public void AddTag_NormalizesDuplicates()
    {
        var prompt = new Prompt("Test", "Content", ModelType.Gpt4);

        prompt.AddTag("Machine Learning");
        prompt.AddTag("machine-learning"); // Same slug
        prompt.AddTag("MACHINE_LEARNING"); // Same slug again

        Assert.Single(prompt.Tags);
    }
}

public class TagTests
{
    [Theory]
    [InlineData("Machine Learning", "machine-learning")]
    [InlineData("AI_Tools", "ai-tools")]
    public void Constructor_NormalizesToSlug(string input, string expected)
    {
        var tag = new Tag(input);
        Assert.Equal(expected, tag.Slug);
    }

    [Fact]
    public void Constructor_WithEmptyValue_Throws()
    {
        Assert.Throws<ArgumentException>(() => new Tag(""));
    }
}
Enter fullscreen mode Exit fullscreen mode

No mocks. No database. Pure logic.


What We Didn't Add

Things often shown in Domain layers that we're skipping:

  1. Aggregate Root base class — Overkill unless you're doing event sourcing
  2. Entity base class with Id — Just put Guid Id on each entity
  3. Domain events — Overkill for this scope
  4. Specification pattern — Query logic belongs in repositories

Keep it simple. Add complexity when you need it.


Domain Project Structure

src/PromptVault.Domain/
├── Entities/
│   ├── Prompt.cs
│   ├── PromptVersion.cs
│   └── Collection.cs
└── ValueObjects/
    ├── Tag.cs
    └── ModelType.cs
Enter fullscreen mode Exit fullscreen mode

No references to EF Core. No references to ASP.NET. The domain is pure.


Key Takeaways

  1. Value objects should earn their place — Don't wrap primitives that have no behavior
  2. Entities enforce invariants — Validation in constructors and methods
  3. Store IDs, not full entities — For many-to-many relationships and performance
  4. Some domains are thin, and that's okay — Don't invent complexity
  5. Pure domain = easy testing — No mocks, no setup, just logic

Coming Up

In Part 3, we'll build the Application layer:

  • Commands and Queries with MediatR
  • When handlers are overkill (and what to use instead)
  • Repository interfaces

👉 Part 3: The Application Layer — CQRS Without the Ceremony


Full source: github.com/yourusername/promptvault

Top comments (0)