Learnixo
Back to blog
AI Systemsintermediate

Shared Kernel — What Belongs Between Modules

Design the Shared Kernel in a modular monolith: what to include (Result, Error, module event contracts), what to exclude, and how to prevent the Shared Kernel from becoming a dumping ground.

Asma Hafeez KhanMay 16, 20265 min read
Modular MonolithShared KernelArchitecture.NETModule Boundaries
Share:𝕏

What the Shared Kernel Is (and Isn't)

Shared Kernel: code shared intentionally across module boundaries.
  It is a deliberate dependency — all modules depend on it.
  Changing it affects all modules simultaneously.

What belongs:
  → Result, Error — universal operation outcome types
  → IModuleEvent — contract for cross-module events
  → Entity base, IDomainEvent — base types for domain primitives
  → Pagination types, common DTOs shared by all modules

What does NOT belong:
  → Patient entity, Prescription entity — those are module-internal
  → PatientRepository — module-internal infrastructure
  → Business logic of any kind — that lives in a module
  → "Utility" classes that only one module uses
  → Database access code

If a class is only used by one module, it is not "shared" — move it inside that module.

Result and Error Types

C#
// SharedKernel/Result.cs
// All modules return Result<T> from use cases — uniform error handling
public sealed class Result<T>
{
    public bool    IsSuccess { get; }
    public bool    IsFailure => !IsSuccess;
    public T       Value     { get; }
    public Error   Error     { get; }

    private Result(T value)              { IsSuccess = true;  Value = value; }
    private Result(Error error)          { IsSuccess = false; Error = error; }

    public static Result<T> Success(T value)    => new(value);
    public static Result<T> Failure(Error error) => new(error);
}

public sealed class Result
{
    public bool  IsSuccess { get; }
    public bool  IsFailure => !IsSuccess;
    public Error Error     { get; }

    private Result(bool success, Error error) { IsSuccess = success; Error = error; }

    public static Result Success()              => new(true, Error.None);
    public static Result Failure(Error error)   => new(false, error);
}

// SharedKernel/Error.cs
public sealed record Error(string Code, string Message)
{
    public static readonly Error None = new(string.Empty, string.Empty);

    public static Error NotFound(string resource, object id) =>
        new($"{resource}.NotFound", $"{resource} with id {id} was not found.");

    public static Error Validation(string field, string message) =>
        new($"Validation.{field}", message);

    public static Error Conflict(string message) =>
        new("Conflict", message);
}

Module Event Contracts

C#
// SharedKernel/IModuleEvent.cs
// Marker interface — all cross-module events implement this
// Events use primitive types only — no domain types cross boundaries
public interface IModuleEvent { }

// SharedKernel/IModuleEventBus.cs
public interface IModuleEventBus
{
    Task PublishAsync<TEvent>(TEvent @event, CancellationToken ct = default)
        where TEvent : class, IModuleEvent;
}

// Module events live in the PUBLISHING module's Api project:
// Patients.Api/Events/PatientAdmittedModuleEvent.cs
public sealed record PatientAdmittedModuleEvent(
    Guid     PatientId,
    string   Mrn,
    string   FullName,
    Guid     WardId,
    DateTime AdmittedAt) : IModuleEvent, INotification;

// Why: events are published by one module and consumed by others.
// The publishing module defines the contract. Subscribers reference Patients.Api (the public API),
// not the SharedKernel — the SharedKernel only defines IModuleEvent.

Entity and Value Object Base Types

C#
// SharedKernel/Entity.cs
// Base for all domain entities — provides domain event collection
public abstract class Entity<TId>
    where TId : notnull
{
    public TId Id { get; protected init; } = default!;

    private readonly List<IDomainEvent> _domainEvents = new();
    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    protected void RaiseDomainEvent(IDomainEvent @event) =>
        _domainEvents.Add(@event);

    public void ClearDomainEvents() =>
        _domainEvents.Clear();
}

// SharedKernel/IDomainEvent.cs
public interface IDomainEvent : INotification { }

// SharedKernel/IAuditable.cs
// Applied to EF Core entities that need audit tracking
public interface IAuditable
{
    DateTime CreatedAt  { get; set; }
    string   CreatedBy  { get; set; }
    DateTime? UpdatedAt { get; set; }
    string?   UpdatedBy { get; set; }
}

// SharedKernel/PagedList.cs
// Reused by all modules for paginated query results
public sealed class PagedList<T>
{
    public IReadOnlyList<T> Items      { get; }
    public int              Page       { get; }
    public int              PageSize   { get; }
    public int              TotalCount { get; }
    public bool             HasNextPage     => Page * PageSize < TotalCount;
    public bool             HasPreviousPage => Page > 1;

    public PagedList(IReadOnlyList<T> items, int page, int pageSize, int totalCount)
    {
        Items = items; Page = page; PageSize = pageSize; TotalCount = totalCount;
    }
}

What Happens When the Shared Kernel Grows Too Large

Warning signs the Shared Kernel has become a dumping ground:
  → More than 20-30 types in SharedKernel
  → Module-specific DTOs appearing in SharedKernel
  → "Helpers", "Extensions", "Utils" namespaces inside SharedKernel
  → Business rules in SharedKernel types
  → Modules not using half of what's in SharedKernel

Consequence:
  → A change to SharedKernel requires testing ALL modules
  → Circular dependency risk: SharedKernel references module assemblies
  → Slow builds: all modules rebuild when SharedKernel changes
  → Developers treat SharedKernel as a junk drawer

Fix:
  → Move module-specific code back into the module
  → If two modules share a concept (not just an ID), reconsider bounded context boundaries
  → SharedKernel should compile in under 2 seconds

SharedKernel Project Structure

SharedKernel/
  Result.cs           → Result, Result
  Error.cs            → Error record
  Entity.cs           → Entity
  IDomainEvent.cs     → IDomainEvent : INotification
  IAuditable.cs       → IAuditable
  IModuleEvent.cs     → IModuleEvent
  IModuleEventBus.cs  → IModuleEventBus
  PagedList.cs        → PagedList
  ValidationExtensions.cs  → FluentValidation helpers (optional)

NOT in SharedKernel:
  Patient.cs         → Patients module
  Prescription.cs    → Prescriptions module
  IPatientRepository → Patients module
  WardService.cs     → Ward module

Production issue I've seen: A team's SharedKernel had 140 files — patient demographics, ward metadata, FHIR reference data, medication lookup tables, clinical decision rules. Every module depended on it, but no module used more than 30% of it. When the clinical decision rules changed (a non-breaking business logic update), all 7 modules required a rebuild and regression test cycle before deployment. The release that should have taken 2 hours took 3 days. Audit found that 80% of SharedKernel content belonged inside specific modules — it had become the "shared everything" antipattern, not a Shared Kernel.


Key Takeaway

SharedKernel contains only what ALL modules genuinely share: Result/Error types, IModuleEvent, Entity base, IDomainEvent, and common pagination types. Module-specific entities, repositories, and business logic stay inside their module. Cross-module event contracts are defined in the publishing module's Api project, not in SharedKernel. Keep SharedKernel small — if it grows beyond 30 types, audit what's actually shared versus what was dropped there for convenience.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.