Architecture Intermediate 12 min

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.

By Victor Robin Updated:

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.

ComponentResponsibility
EntityBase class providing Identity and Equality behaviors.
ValueObjectBase class providing Structural Equality.
AggregateRootBase class for roots, managing Domain Events.
CustomIdA strongly-typed, human-friendly identifier (NanoID).
[Domain-Driven Design: Tackling Complexity in the Heart of Software] — Eric Evans , 2003

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?

  1. URL Friendliness: 8-char alphanumeric strings are cleaner in URLs than GUIDs.
  2. Type Safety: Prevents mixing up UserId and DocumentId (Primitive Obsession).
  3. 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

Further Reading

[Shared Kernel Pattern] — Eric Evans , 2015 [C# Records - Microsoft Documentation] — Microsoft , 2024