Shared Kernel in Vertical Slice ā What to Share and What Not To
Design the Shared Kernel in Vertical Slice Architecture: Result type, Error type, MediatR behaviors, domain primitives, what belongs there versus in feature folders, and avoiding the SharedKernel dumping-ground anti-pattern.
What Shared Kernel Means
Shared Kernel: code that is genuinely needed across multiple features.
Not: "code I don't know where to put"
Not: "code that might be useful someday"
Yes: "this exact thing is used by 5+ features today"
The test: if you delete the SharedKernel, how many features break?
If most features break: it contains genuine shared infrastructure.
If 1-2 features break: those things probably belong in those features.Result Type
// SharedKernel/Models/Result.cs
// Used by every handler ā genuinely shared
public sealed class Result
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public Error Error { get; }
private Result(bool isSuccess, Error error)
{
IsSuccess = isSuccess;
Error = error;
}
public static Result Success() => new(true, Error.None);
public static Result Failure(Error error) => new(false, error);
public static Result<T> Success<T>(T value) => new(value, true, Error.None);
public static Result<T> Failure<T>(Error e) => new(default!, false, e);
}
public sealed class Result<T>
{
public T Value { get; }
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public Error Error { get; }
internal Result(T value, bool isSuccess, Error error)
{
Value = value;
IsSuccess = isSuccess;
Error = error;
}
}Error Type
// SharedKernel/Models/Error.cs
public sealed record Error(string Code, string Description)
{
public static readonly Error None = new(string.Empty, string.Empty);
public static Error NotFound(string entity, object id) =>
new($"{entity}.NotFound", $"{entity} 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);
}
// Domain errors per feature or aggregate
// Features/Patients/DomainErrors.cs (or SharedKernel if referenced by many)
public static class DomainErrors
{
public static class Patient
{
public static readonly Error NotFound =
new("Patient.NotFound", "Patient was not found.");
public static readonly Error AlreadyAdmitted =
new("Patient.AlreadyAdmitted", "Patient is already admitted to a ward.");
public static readonly Error MrnAlreadyExists =
new("Patient.MrnAlreadyExists", "A patient with this MRN already exists.");
}
public static class Prescription
{
public static readonly Error NotFound =
new("Prescription.NotFound", "Prescription was not found.");
public static readonly Error InvalidDose =
new("Prescription.InvalidDose", "The specified dose is outside the safe range.");
public static Error ConcurrencyConflict(decimal currentDose) =>
new("Prescription.ConcurrencyConflict",
$"This prescription was modified. Current dose: {currentDose}mg.");
}
}MediatR Pipeline Behaviors
// SharedKernel/Behaviors/PerformanceBehavior.cs
// Flags handlers that take longer than expected ā used across all features
public sealed class PerformanceBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly ILogger<PerformanceBehavior<TRequest, TResponse>> _logger;
private const int SlowHandlerThresholdMs = 500;
public PerformanceBehavior(ILogger<PerformanceBehavior<TRequest, TResponse>> logger)
=> _logger = logger;
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
var sw = Stopwatch.StartNew();
var response = await next();
sw.Stop();
if (sw.ElapsedMilliseconds > SlowHandlerThresholdMs)
{
_logger.LogWarning(
"Slow handler: {RequestName} took {ElapsedMs}ms",
typeof(TRequest).Name,
sw.ElapsedMilliseconds);
}
return response;
}
}Domain Primitives
// SharedKernel/Domain/Entity.cs
// Base type for domain entities ā used across all aggregates
public abstract class Entity<TId>
{
public TId Id { get; protected set; } = default!;
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void RaiseDomainEvent(IDomainEvent domainEvent)
=> _domainEvents.Add(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();
protected bool Equals(Entity<TId> other) =>
EqualityComparer<TId>.Default.Equals(Id, other.Id);
public override bool Equals(object? obj) =>
obj is Entity<TId> entity && Equals(entity);
public override int GetHashCode() => Id!.GetHashCode();
}
// SharedKernel/Domain/IDomainEvent.cs
public interface IDomainEvent : INotification { }Shared Infrastructure Contracts
// SharedKernel/Interfaces/IUnitOfWork.cs
// Used by every handler that modifies data
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct = default);
}
// SharedKernel/Interfaces/ICurrentUser.cs
// Used by multiple features (authorization, audit logging)
public interface ICurrentUser
{
Guid? UserId { get; }
string? UserName { get; }
Guid? TenantId { get; }
bool IsAuthenticated { get; }
bool HasRole(string role);
}
// SharedKernel/Interfaces/IAuditLogger.cs
// Used for audit trails across features
public interface IAuditLogger
{
Task RecordAsync(AuditEntry entry, CancellationToken ct = default);
}What Does NOT Belong in SharedKernel
ā PatientRepository ā only Patient feature uses it ā put it in the feature
ā PrescriptionValidator ā only Prescription feature uses it ā keep it co-located
ā EmailService ā used by Notifications feature only ā in that feature
ā WarfarinDoseCalculator ā used by one feature ā belongs there
ā GetPatientResponse ā only the GetPatient feature uses it ā in that folder
The test:
"If I remove this class from SharedKernel, does more than one feature break?"
YES ā it belongs in SharedKernel
NO ā it belongs in the feature that uses itProduction issue I've seen: A team gradually added every "utility" class to SharedKernel. After 12 months: 60 files, including feature-specific validators, DTO mappers, query helpers, and business rule checks used by only one handler. The SharedKernel became the largest folder in the codebase and the most edited. Every feature PR touched SharedKernel, causing merge conflicts and making it impossible to review feature changes in isolation. The discipline is: SharedKernel grows slowly and only with genuine cross-feature types.
Key Takeaway
SharedKernel contains:
Result<T>,Error,DomainEvent,Entity<TId>, pipeline behaviors, shared interfaces (IUnitOfWork,ICurrentUser). It does NOT contain feature-specific logic, validators, or responses. The test for inclusion: "does more than one feature use this exact type?" If no, it belongs in the feature. A large SharedKernel is a code smell ā it means feature logic is leaking out of feature folders.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.