Building a DDD Shared Kernel in .NET
Implementation of the Shared Kernel pattern: A crucial library containing the base building blocks (Entity, AggregateRoot, ValueObject) for Domain-Driven Design.
Introduction
When I started building BlueRobin, every module had its own definition of what an ‘Entity’ looked like. Archives.Core used GUIDs, the Workers used strings, and the Infrastructure layer had its own base classes. This inconsistency was a nightmare for serialization and testing. The Shared Kernel was my answer—a single library that defines the tactical building blocks everyone must use.
This article details the production implementation of our Shared Kernel, focusing on type safety, immutability, and enforcing DDD rules at the compiler level.
Why a Shared Kernel?
Without a Shared Kernel, each microservice or module might implement “Entity” or “Domain Event” differently, leading to inconsistent behavior and serialization issues. The Shared Kernel acts as the “Standard Library” for your domain architecture.
| Component | Responsibility |
|---|---|
| Entity | Base class providing Identity and Equality behaviors. |
| ValueObject | Base class providing Structural Equality. |
| AggregateRoot | Base class for roots, managing Domain Events. |
| CustomId | A strongly-typed, human-friendly identifier (NanoID). |
Architecture Overview
The Shared Kernel sits at the bottom of the dependency graph. It has no dependencies on infrastructure or specific domains.
flowchart TB
subgraph SK["📦 SharedKernel Library"]
direction TB
Entity["🏷️ Entity<TId>"]
AggRoot["📄 AggregateRoot<TId>"]
VO["💎 ValueObject"]
Event["📣 IDomainEvent"]
BRId["🔑 CustomId"]
AggRoot --> Entity
end
subgraph Core["🧱 MyApp.Core"]
Doc["Document"] --> AggRoot
FP["Fingerprint"] --> VO
end
subgraph Infra["⚙️ MyApp.Infrastructure"]
Repo["EF Repository"] -.-> AggRoot
end
Core --> SK
Infra --> SK
classDef primary fill:#7c3aed,color:#fff
classDef secondary fill:#06b6d4,color:#fff
classDef db fill:#f43f5e,color:#fff
classDef warning fill:#fbbf24,color:#000
class Core primary
class SK secondary
class Infra db
Implementation
The Identity Strategy: CustomId
We use a specialized, strongly-typed identifier key called CustomId instead of raw GUIDs or Integers.
Why?
- URL Friendliness: 8-char alphanumeric strings are cleaner in URLs than GUIDs.
- Type Safety: Prevents mixing up
UserIdandDocumentId(Primitive Obsession). - Consistency: Used everywhere—Postgres Primary Keys, MinIO Object Keys, NATS Subjects.
// SharedKernel/Utilities/CustomId.cs
using NanoidDotNet;
public readonly struct CustomId : IEquatable<CustomId>, IComparable<CustomId>
{
private const string Alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
private const int Size = 8;
public string Value { get; }
// Private constructor forces validation
private CustomId(string value) => Value = value;
public static CustomId New() => new(Nanoid.Generate(Alphabet, Size));
public static Result<CustomId> Create(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return Result.Failure<CustomId>(new Error("Identity.Empty", "ID cannot be empty"));
var normalized = value.Trim().ToLowerInvariant();
if (normalized.Length != Size || !normalized.All(c => Alphabet.Contains(c)))
return Result.Failure<CustomId>(new Error("Identity.Invalid", "Invalid ID format"));
return Result.Success(new CustomId(normalized));
}
// Equality and Operators
public override bool Equals(object? obj) => obj is CustomId other && Equals(other);
public bool Equals(CustomId other) => Value == other.Value;
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value;
public static implicit operator string(CustomId id) => id.Value;
}
[NanoID - A tiny, secure, URL-friendly, unique string ID generator]
— Andrey Sitnik , 2024
The Entity Base Class
Entities are objects defined by their identity. Two entities with different attributes but the same ID are considered the same entity.
// SharedKernel/Entity.cs
public abstract class Entity
{
// Protected set: ID should only be set during construction
public CustomId Id { get; protected set; }
protected Entity(CustomId id)
{
Id = id;
}
// EF Core constructor
protected Entity() { }
}
[.NET Domain-Driven Design Guidance]
— Microsoft , 2024
The Aggregate Root
The AggregateRoot combines Entity behavior with the ability to record domain events. It serves as a transaction boundary.
// SharedKernel/AggregateRoot.cs
public abstract class AggregateRoot : Entity, IAggregateRoot
{
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected AggregateRoot(CustomId id) : base(id) { }
protected AggregateRoot() { }
protected void RaiseDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}
[Implementing Domain-Driven Design]
— Vaughn Vernon , 2013
The Value Object Base Class
For Value Objects, which have no identity, we implement structural equality.
// SharedKernel/ValueObject.cs
public abstract class ValueObject : IEquatable<ValueObject>
{
protected abstract IEnumerable<object?> GetEqualityComponents();
public override bool Equals(object? obj)
{
if (obj is null || obj.GetType() != GetType()) return false;
return Equals((ValueObject)obj);
}
public bool Equals(ValueObject? other)
{
return other is not null && GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
}
public override int GetHashCode()
{
return GetEqualityComponents()
.Aggregate(0, (hash, current) => HashCode.Combine(hash, current));
}
public static bool operator ==(ValueObject? a, ValueObject? b)
{
if (a is null && b is null) return true;
if (a is null || b is null) return false;
return a.Equals(b);
}
public static bool operator !=(ValueObject? a, ValueObject? b) => !(a == b);
}
[Value Object Pattern]
— Martin Fowler , 2016
Conclusion
The Shared Kernel has been one of the most valuable investments in this project. By standardizing CustomId and the base classes for DDD tactical patterns, I’ve ensured that every service speaks the same language and adheres to the same structural rules. When I later added the Finance and Health modules, they immediately inherited all these patterns without reinventing them.
Next Steps
- Learn how to enforce invariants with factory methods to prevent invalid state.
- Explore Value Objects in depth for self-validating domain primitives.
- See how Aggregates build on these base classes.