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);
🔥 Real talk: Most of the time, this is pointless ceremony. You've created a wrapper that does nothing but add
.Valueeverywhere.
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;
}
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);
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; }
Simple. Works. But any string is accepted.
Option 2: Enum
public enum ModelType { Gpt4, Gpt4Turbo, Claude3Opus }
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";
}
}
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;
}
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
- Every entity method is just a setter in disguise:
public void UpdateTitle(string title) => Title = title;
All your "business rules" are just null checks and length limits
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;
}
}
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(""));
}
}
No mocks. No database. Pure logic.
What We Didn't Add
Things often shown in Domain layers that we're skipping:
- Aggregate Root base class — Overkill unless you're doing event sourcing
-
Entity base class with Id — Just put
Guid Idon each entity - Domain events — Overkill for this scope
- 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
No references to EF Core. No references to ASP.NET. The domain is pure.
Key Takeaways
- Value objects should earn their place — Don't wrap primitives that have no behavior
- Entities enforce invariants — Validation in constructors and methods
- Store IDs, not full entities — For many-to-many relationships and performance
- Some domains are thin, and that's okay — Don't invent complexity
- 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)