Shared Kernel
The Shared Kernel is a strategic pattern from Domain-Driven Design (DDD) that contains code shared across multiple bounded contexts (modules) in your application.
The Shared Kernel should be small, well-defined, and changed only with careful coordination, as modifications impact all modules that depend on it.
What is the Shared Kernel?
In a modular hexagonal architecture, each module (bounded context) should be as independent as possible. However, some concepts are truly generic and used across multiple modules. The Shared Kernel is where you place these common building blocks.
Purpose
The Shared Kernel serves to:
- Prevent Code Duplication - Avoid reimplementing the same value objects or utilities in every module
- Ensure Consistency - Guarantee that common concepts (like Email, Money, UUID) behave identically everywhere
- Maintain Independence - Allow modules to share code without creating tight coupling between them
- Express Ubiquitous Language - Centralize domain concepts that transcend individual bounded contexts
Directory Structure
src/
βββ Module/ # Your bounded contexts
β βββ User/
β β βββ Account/
β βββ Blog/
β β βββ Post/
β βββ Order/
β βββ Checkout/
βββ Shared/ # Shared Kernel
βββ Domain/
β βββ ValueObject/ # Generic value objects
β β βββ Email.php
β β βββ Money.php
β β βββ Uuid.php
β β βββ PhoneNumber.php
β βββ Exception/ # Base exceptions
β β βββ DomainException.php
β β βββ ValidationException.php
β βββ Event/ # Base event classes
β βββ DomainEvent.php
βββ Application/
β βββ Service/ # Generic application services
β βββ Clock.php # Time abstraction
βββ Infrastructure/
βββ Persistence/ # Generic persistence utilities
β βββ Doctrine/
β βββ Type/ # Custom Doctrine types
βββ Messaging/ # Shared messaging infrastructure
What Belongs in Shared Kernel?
Good Candidates for Shared Kernel
Generic Value Objects
Email- Used by User, Newsletter, Support modulesMoney- Used by Order, Invoice, Payment modulesUuid- Used across all modules for entity IDsPhoneNumber- Used by User, Shipping, Contact modulesAddress- Used by User, Order, Shipping modules (if truly generic)
Base Domain Concepts
- Abstract base exceptions (
DomainException) - Domain event interfaces
- Common enums (Country, Currency, Language)
- Measurement units (Weight, Distance)
Infrastructure Utilities
- Clock interface for testable time
- Common Doctrine custom types
- Shared event bus configuration
What Should NOT be in Shared
Context-Specific Logic
UserEmail(includes user-specific validation like βno admin emailsβ) β Keep in User moduleOrderTotal(includes tax calculation logic) β Keep in Order moduleProductPrice(includes pricing rules) β Keep in Product module
Business Rules
- Any validation that differs by context
- Behavior specific to one domain
Premature Abstractions
- Code used by only 1-2 modules (wait until 3+)
- βMight be shared somedayβ code
Examples
Example 1: Shared Email Value Object
<?php
// src/Shared/Domain/ValueObject/Email.php
namespace App\Shared\Domain\ValueObject;
final readonly class Email
{
public function __construct(private string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException(
"'{$value}' is not a valid email address"
);
}
}
public function getValue(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}
Used across multiple modules:
// User module - User entity
namespace App\User\Account\Domain\Model;
use App\Shared\Domain\ValueObject\Email;
final class User
{
public function __construct(
private UserId $id,
private Email $email, // β Shared Email
private string $name
) {}
}
// Newsletter module - Subscriber entity
namespace App\Newsletter\Domain\Model;
use App\Shared\Domain\ValueObject\Email;
final class Subscriber
{
public function __construct(
private SubscriberId $id,
private Email $email, // β Same shared Email
private bool $active
) {}
}
// Support module - Ticket entity
namespace App\Support\Ticket\Domain\Model;
use App\Shared\Domain\ValueObject\Email;
final class Ticket
{
public function __construct(
private TicketId $id,
private Email $customerEmail, // β Same shared Email
private string $subject
) {}
}
Example 2: Shared Money Value Object
<?php
// src/Shared/Domain/ValueObject/Money.php
namespace App\Shared\Domain\ValueObject;
final readonly class Money
{
public function __construct(
private int $amount, // Store as cents/minor units
private Currency $currency
) {
if ($amount < 0) {
throw new \InvalidArgumentException('Amount cannot be negative');
}
}
public function getAmount(): int
{
return $this->amount;
}
public function getCurrency(): Currency
{
return $this->currency;
}
public function add(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->amount + $other->amount, $this->currency);
}
public function subtract(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->amount - $other->amount, $this->currency);
}
public function multiply(int $multiplier): self
{
return new self($this->amount * $multiplier, $this->currency);
}
private function assertSameCurrency(self $other): void
{
if (!$this->currency->equals($other->currency)) {
throw new \InvalidArgumentException(
'Cannot operate on different currencies'
);
}
}
public function equals(self $other): bool
{
return $this->amount === $other->amount
&& $this->currency->equals($other->currency);
}
}
// src/Shared/Domain/ValueObject/Currency.php
enum Currency: string
{
case USD = 'USD';
case EUR = 'EUR';
case GBP = 'GBP';
}
Used in Order and Invoice modules:
// Order module
use App\Shared\Domain\ValueObject\Money;
use App\Shared\Domain\ValueObject\Currency;
final class Order
{
private Money $total;
public function calculateTotal(): void
{
$this->total = new Money(0, Currency::USD);
foreach ($this->items as $item) {
$this->total = $this->total->add($item->getPrice());
}
}
}
// Invoice module
use App\Shared\Domain\ValueObject\Money;
final class Invoice
{
public function __construct(
private InvoiceId $id,
private Money $amount, // β Shared Money
private Money $taxAmount // β Shared Money
) {}
}
Example 3: Shared UUID Value Object
<?php
// src/Shared/Domain/ValueObject/Uuid.php
namespace App\Shared\Domain\ValueObject;
use Symfony\Component\Uid\Uuid as SymfonyUuid;
abstract readonly class Uuid
{
protected function __construct(private string $value)
{
if (!SymfonyUuid::isValid($value)) {
throw new \InvalidArgumentException("Invalid UUID: {$value}");
}
}
public static function generate(): static
{
return new static(SymfonyUuid::v4()->toRfc4122());
}
public static function fromString(string $value): static
{
return new static($value);
}
public function getValue(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}
Each module extends it with their own typed ID:
// User module
namespace App\User\Account\Domain\ValueObject;
use App\Shared\Domain\ValueObject\Uuid;
final readonly class UserId extends Uuid {}
// Order module
namespace App\Order\Checkout\Domain\ValueObject;
use App\Shared\Domain\ValueObject\Uuid;
final readonly class OrderId extends Uuid {}
// Blog module
namespace App\Blog\Post\Domain\ValueObject;
use App\Shared\Domain\ValueObject\Uuid;
final readonly class PostId extends Uuid {}
Why extend instead of direct use?
- Type Safety -
UserIdβOrderIdat compile time - Clarity - Intent is explicit in method signatures
- Flexibility - Each module can add specific behavior later
When to Move Code to Shared
The βRule of Threeβ
Wait until 3+ modules need it:
Module A needs Email β Keep in Module A
Module B also needs Email β Duplicate or extract to Shared? β Wait
Module C also needs Email β Now extract to Shared!
Decision Checklist
Ask these questions before moving code to Shared:
- Is it truly generic?
- Yes:
Email- Same validation everywhere - No:
UserEmail- Might have user-specific rules
- Yes:
- Does it have zero business logic?
- Yes:
PhoneNumber- Just format and validation - No:
CustomerDiscount- Contains pricing rules
- Yes:
- Will all modules use it the same way?
- Yes:
Uuid- Identity concept is universal - No:
Status- Each module has different status workflows
- Yes:
- Is it stable?
- Yes:
Money- Well-established pattern - No:
Notification- Still evolving per module needs
- Yes:
If you answer βYesβ to all β Move to Shared If you answer βNoβ to any β Keep in module
Anti-Patterns to Avoid
Shared Becoming a βJunk Drawerβ
Bad:
Shared/
βββ Utils/
β βββ StringHelper.php
β βββ ArrayHelper.php
β βββ MiscFunctions.php β Avoid!
Good:
Shared/
βββ Domain/
β βββ ValueObject/
β βββ Email.php β Clear purpose
β βββ Money.php β Clear purpose
Premature Extraction
Bad:
// After first use in User module
// "This might be shared someday..."
mv User/ValueObject/Email.php Shared/ValueObject/Email.php β Too early!
Good:
// After 3rd module needs it
// "Now it's proven to be generic"
mv User/ValueObject/Email.php Shared/ValueObject/Email.php β Right time!
Business Logic in Shared
Bad:
// Shared/Domain/ValueObject/Price.php
final class Price
{
public function applyDiscount(): self
{
// Discount logic belongs in Order or Product module!
if ($this->customer->isPremium()) {
return $this->multiply(0.9);
}
}
}
Good:
// Shared/Domain/ValueObject/Money.php
final readonly class Money
{
// Pure value object - no business rules
public function multiply(float $factor): self
{
return new self((int)($this->amount * $factor), $this->currency);
}
}
// Order/Domain/Service/PricingService.php
final class PricingService
{
// Business logic stays in module
public function applyDiscount(Money $price, Customer $customer): Money
{
if ($customer->isPremium()) {
return $price->multiply(0.9);
}
return $price;
}
}
Generating Shared Code
Generate Shared Value Objects
# Generate in Shared namespace
bin/console make:hexagonal:value-object shared Email
This creates:
src/Shared/Domain/ValueObject/Email.php
Generate Shared Exceptions
bin/console make:hexagonal:exception shared ValidationException
This creates:
src/Shared/Domain/Exception/ValidationException.php
Managing Shared Kernel Changes
Coordination is Key
Changes to Shared affect all modules. Follow these rules:
- Backward Compatibility - Donβt break existing modules
- Team Agreement - Discuss changes with all module owners
- Version Carefully - Consider Shared as an internal βlibraryβ
- Test Thoroughly - Changes impact multiple contexts
Safe Change Example
Before:
final readonly class Email
{
public function getValue(): string
{
return $this->value;
}
}
After (backward compatible):
final readonly class Email
{
public function getValue(): string
{
return $this->value;
}
// New method - doesn't break existing code
public function getDomain(): string
{
return explode('@', $this->value)[1];
}
}
Breaking Change (Avoid!)
Bad:
final readonly class Email
{
// Renamed - breaks all modules!
public function value(): string // was getValue()
{
return $this->value;
}
}
Best Practices
- Keep it Small - Shared Kernel should be minimal
- Wait for Patterns - Donβt prematurely extract
- No Business Logic - Only pure, generic concepts
- Document Well - Clear docs on what belongs in Shared
- Version Changes - Treat Shared as a contract
- Test Coverage - High test coverage for Shared code
Summary
The Shared Kernel is for:
- Generic value objects (Email, Money, UUID)
- Common base exceptions
- Infrastructure utilities
- Concepts used by 3+ modules
The Shared Kernel is NOT for:
- Business logic specific to one context
- Code used by only 1-2 modules
- Unstable or evolving concepts
- Context-specific variations
Key principle: When in doubt, keep it in the module. Extract to Shared only when the need is proven and clear.