The Common Tongue: Building the Shared Kernel & Result Pattern
Stop using Exceptions for flow control. How to implement the DNA of your Domain Layer.
In Part 1, we scaffolded the PMCR-O project tree. We have a Domain project, but it is empty.
Many developers rush to fill this with "Anemic Models"—classes that are just bags of getters and setters. They rely on throwing Exceptions when things go wrong. This is expensive (performance-wise) and messy (logic-wise).
Today, we define the Shared Kernel. These are the fundamental rules that every entity in your system will obey.
The Strategy
We are going to implement three architectural pillars inside ProjectName.Domain:
BaseEntity<TKey>: To enforce ID consistency and Domain Events.
Result<T>: To replace try/catch with explicit success/failure returns.
IDomainEvent: To prepare our system for event-driven behavior.
Step 1: The Result Pattern (Killing Exceptions)
Exceptions should be reserved for exceptional circumstances (e.g., the Database is on fire). They should not be used for logic (e.g., "User not found").
Create a folder ProjectName.Domain/Shared.
Create a file Result.cs.
This pattern allows us to say: "I tried to do X. Did it work? If not, here is exactly why."
namespace ProjectName.Domain.Shared;
public class Result
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public Error Error { get; }
protected Result(bool isSuccess, Error error)
{
if (isSuccess && error != Error.None)
throw new InvalidOperationException();
if (!isSuccess && error == Error.None)
throw new InvalidOperationException();
IsSuccess = isSuccess;
Error = error;
}
public static Result Success() => new(true, Error.None);
public static Result Failure(Error error) => new(false, error);
}
// The Generic Version (for returning data)
public class Result<T> : Result
{
private readonly T? _value;
protected Result(T? value, bool isSuccess, Error error)
: base(isSuccess, error)
{
_value = value;
}
public T Value => IsSuccess
? _value!
: throw new InvalidOperationException("The value of a failure result can not be accessed.");
public static Result<T> Success(T value) => new(value, true, Error.None);
public static new Result<T> Failure(Error error) => new(default, false, error);
}
Note: You will need a simple Error record type (Code/Message) to support this.
Step 2: The Base Entity (The DNA)
We don't want to repeat Id, CreatedAt, or Event logic in every class. We abstract it.
We use TKey so some entities can use Guid and others can use int or long.
Create ProjectName.Domain/Primitives/BaseEntity.cs:
namespace ProjectName.Domain.Primitives;
public abstract class BaseEntity<TKey> : IEquatable<BaseEntity<TKey>>
{
public TKey Id { get; protected set; }
private readonly List<IDomainEvent> _domainEvents = new();
// Protection: We expose a ReadOnly collection so outside classes can't tamper with events.
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected BaseEntity(TKey id)
{
Id = id;
}
// Add an event (e.g., "ArticlePublished") to be fired after transaction commit
protected void RaiseDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
// Equality Implementation (Two entities are equal if their IDs are equal)
public bool Equals(BaseEntity<TKey>? other)
{
if (other is null) return false;
if (ReferenceEquals(this, other)) return true;
return EqualityComparer<TKey>.Default.Equals(Id, other.Id);
}
public override bool Equals(object? obj)
{
if (obj is null) return false;
if (ReferenceEquals(this, obj)) return true;
if (obj.GetType() != GetType()) return false;
return Equals((BaseEntity<TKey>)obj);
}
public override int GetHashCode()
{
return EqualityComparer<TKey>.Default.GetHashCode(Id);
}
}
Step 3: Domain Events (The Echo)
A Domain Event is something that happened in the past that the business cares about.
Create ProjectName.Domain/Primitives/IDomainEvent.cs:
using MediatR; // Reference MediatR.Contracts
namespace ProjectName.Domain.Primitives;
// It inherits INotification so MediatR can dispatch it later
public interface IDomainEvent : INotification
{
Guid Id { get; }
}
Step 4: putting It Together (The Usage)
Now, let's look at how this changes your code. Imagine we are building the Article entity.
The Old Way (Bad):
public void Publish() {
if (IsPublished) throw new Exception("Already published"); // Expensive!
IsPublished = true;
}
The PMCR-O Way (Clean):
public Result Publish()
{
if (IsPublished)
{
return Result.Failure(DomainErrors.Article.AlreadyPublished);
}
IsPublished = true;
// We record the event, but we don't fire it yet.
// The Infrastructure layer will fire this ONLY if the Database saves successfully.
RaiseDomainEvent(new ArticlePublishedEvent(this.Id));
return Result.Success();
}
Why This Wins
Expressiveness: The method signature Result tells the caller "This might fail, and you need to handle it."
Performance: No stack trace generation for validation errors.
Decoupling: The BaseEntity captures events (Side Effects) without knowing who handles them. The Domain remains pure.
In Part 3, we will move to the Infrastructure Layer and implement the generic repositories that read these entities and save them to the database.