ahmed-bhs/hexagonal-maker-bundle
The Shared Kernel is a strategic pattern from Domain-Driven Design (DDD) that contains code shared across multiple bounded contexts (modules) in your application.
{: .note } The Shared Kernel should be small, well-defined, and changed only with careful coordination, as modifications impact all modules that depend on it.
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.
The Shared Kernel serves to:
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
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
DomainException)Infrastructure Utilities
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 moduleBusiness Rules
Premature Abstractions
<?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
) {}
}
<?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
) {}
}
<?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?
UserId ≠ OrderId at compile timeWait 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!
Ask these questions before moving code to Shared:
Is it truly generic?
Email - Same validation everywhereUserEmail - Might have user-specific rulesDoes it have zero business logic?
PhoneNumber - Just format and validationCustomerDiscount - Contains pricing rulesWill all modules use it the same way?
Uuid - Identity concept is universalStatus - Each module has different status workflowsIs it stable?
Money - Well-established patternNotification - Still evolving per module needsIf you answer "Yes" to all → Move to Shared If you answer "No" to any → Keep in module
Bad:
Shared/
├── Utils/
│ ├── StringHelper.php
│ ├── ArrayHelper.php
│ └── MiscFunctions.php ← Avoid!
Good:
Shared/
├── Domain/
│ └── ValueObject/
│ ├── Email.php ← Clear purpose
│ └── Money.php ← Clear purpose
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!
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;
}
}
# Generate in Shared namespace
bin/console make:hexagonal:value-object shared Email
This creates:
src/Shared/Domain/ValueObject/Email.php
bin/console make:hexagonal:exception shared ValidationException
This creates:
src/Shared/Domain/Exception/ValidationException.php
Changes to Shared affect all modules. Follow these rules:
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];
}
}
Bad:
final readonly class Email
{
// Renamed - breaks all modules!
public function value(): string // was getValue()
{
return $this->value;
}
}
The Shared Kernel is for:
The Shared Kernel is NOT for:
Key principle: When in doubt, keep it in the module. Extract to Shared only when the need is proven and clear.
How can I help you explore Laravel packages today?