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.
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
// 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
// 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
// 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 secondsSharedKernel 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.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.