Index

Shared Kernel or Published Language: Choose Where Coordination Lives

Billing and fulfilment both use the word “order.” Sharing an Order class looks like the obvious way to keep them consistent.

Then billing adds credit notes, tax evidence, and settlement state. Fulfilment adds picking waves, split shipments, and packing exceptions. The shared class either accumulates both models or becomes a negotiation every time one team changes it.

Integration always creates coordination. A Shared Kernel and a Published Language place that coordination in different locations. The first shares a small piece of model and requires teams to change it together. The second shares a versioned language at the boundary and lets each side translate it into its own model.

A Shared Kernel is joint ownership, not a common folder

Eric Evans’s DDD reference defines a Shared Kernel as an explicitly bounded subset of model and implementation that teams agree to share, keep small, and change through consultation and continuous integration. The important word is not “shared.” It is “own.”

Consider two modules in one Laravel application that use the same immutable order identifier:

namespace Shared\Orders;

use Illuminate\Support\Str;
use InvalidArgumentException;

final readonly class OrderId
{
    public function __construct(public string $value)
    {
        if (! Str::isUuid($value)) {
            throw new InvalidArgumentException('Order ID must be a UUID.');
        }
    }
}

Both modules agree on identity format and equality. Sharing this type prevents each side from inventing a subtly different representation, and a change to the invariant should affect both sides.

That does not justify sharing the aggregate:

Shared/Orders/Order.php
Shared/Orders/OrderLine.php
Shared/Orders/OrderStatus.php
Shared/Orders/OrderRepository.php

Status is already suspicious. Billing may distinguish invoiced and settled; fulfilment may distinguish allocated and shipped. One enum either loses those meanings or forces every consumer to depend on states it does not use.

A kernel remains healthy when:

  • the shared concepts have the same meaning and invariants in both contexts;
  • the owners can review and release changes together;
  • consumers run compatible versions; and
  • the shared surface stays smaller than the models around it.

These conditions are easiest inside one deployment and team boundary. A Composer package does not create independence if two services must upgrade it in lockstep. It only moves the coupling into a package registry.

A Published Language makes translation explicit

Suppose fulfilment must consume orders independently of billing releases. Billing can publish a stable event contract:

{
  "type": "billing.order-paid.v1",
  "id": "evt_01JZ...",
  "occurred_at": "2026-07-18T12:15:00Z",
  "data": {
    "order_id": "0198...",
    "currency": "EUR",
    "amount_minor": 12900
  }
}

This is a Published Language: Evans describes it as a documented shared language used as the translation medium between models. Producers and consumers implement it without sharing their internal objects. JSON is only the encoding. Field meaning, units, optionality, compatibility rules, and failure behavior are the language.

Fulfilment translates the message at its boundary:

final readonly class PaidOrderMessage
{
    public static function fromArray(array $payload): self
    {
        return new self(
            eventId: EventId::fromString($payload['id']),
            orderId: FulfilmentOrderId::fromString(
                $payload['data']['order_id'],
            ),
            paidAmount: Money::ofMinor(
                $payload['data']['amount_minor'],
                $payload['data']['currency'],
            ),
        );
    }
}

The adapter is intentional duplication. It prevents billing’s concepts and library choices from becoming fulfilment’s domain model. It is also the place to reject an unknown version or invalid unit before bad data crosses the boundary.

Independent deployment now costs more work:

  • the schema needs compatibility rules and examples;
  • producers must not remove fields while supported consumers need them;
  • consumers need contract tests and unknown-message handling;
  • when the transport can redeliver, event identity and idempotent handling matter; and
  • operational ownership must cover poison messages and lag.

The boundary is not decoupled. Its coupling is explicit, serializable, and versioned.

A shared schema can be a disguised Shared Kernel

Generating producer and consumer classes from one schema repository is often useful. It can also recreate lockstep deployment if every schema change forces all applications to upgrade immediately.

The deciding test is compatibility, not code generation. Can an older consumer safely process messages from the new producer? Can the producer continue emitting the supported version while consumers migrate? If not, the organization has a shared kernel with a network in the middle—and the failure modes of both patterns.

Conversely, a Shared Kernel need not be careless. It can have focused contract tests and semantic versioning. Those practices improve coordination but do not remove joint ownership.

The context map may choose neither pattern

Shared Kernel and Published Language answer questions about shared model and shared interchange. A context map must also record power, translation ownership, consumer count, and whether integration is valuable at all. Those pressures lead to five adjacent choices in the same DDD reference:

SituationMore honest relationship
Billing plans for Fulfilment’s requirements and accepts its contract tests.Customer/Supplier
Billing will not adapt, and its model is adequate for Fulfilment.Conformist
Billing will not adapt, but its model would distort Fulfilment.Anticorruption Layer
Billing serves many consumers through one coherent protocol.Open-host Service, often with a Published Language
The integration costs more than the business relationship is worth.Separate Ways

A Published Language does not prove that the producer listens to downstream needs. That is an organizational Customer/Supplier decision. An Open-host Service defines a reusable access protocol for many consumers; the Published Language defines the information exchanged through it. A downstream Anticorruption Layer or Conformist stance decides what happens after that language reaches the consumer.

For Billing and Fulfilment, start with the relationship that exists rather than the one the architecture diagram would like to show. If Fulfilment has no influence over Billing’s roadmap, calling the teams partners hides the most important constraint. If Fulfilment does not need Billing data at all, a carefully versioned contract is still wasted integration.

Start with the deployment and change constraints

Use a Shared Kernel when two contexts genuinely share a small invariant and their owners can coordinate its changes. It is especially reasonable for modules in one application where translating OrderId at every call adds no independence.

Use a Published Language when consumers need their own models, release cadence, technology, or availability boundary. Pay for schemas, adapters, compatibility, and operations because those costs buy a specific form of independence.

Do not choose a message broker merely because a context diagram has two boxes. A synchronous HTTP API can publish a language; an event can publish one asynchronously. Transport follows latency, delivery, and availability needs.

Prove the boundary with different tests

A Shared Kernel needs tests for the invariants every owner accepts:

it('rejects non-UUID order identifiers', function () {
    expect(fn () => new OrderId('order-123'))
        ->toThrow(InvalidArgumentException::class);
});

A Published Language needs producer and consumer contract evidence. Keep canonical examples with the schema, validate producer output against them, and run those same examples through each consumer adapter:

it('translates the supported paid-order contract', function () {
    $payload = jsonFixture('billing.order-paid.v1.json');

    $message = PaidOrderMessage::fromArray($payload);

    expect($message->paidAmount)->toEqual(Money::EUR(129_00));
});

Also test the change that is supposed to be compatible. Adding an optional field is safe only if deployed consumers actually ignore it and producers do not require old messages to contain it.

Make the coordination mechanism honest

The attractive promise of a Shared Kernel is zero translation. Its cost is joint change. The attractive promise of a Published Language is independent models. Its cost is a product-like contract that must survive time and failure.

Choose the place where coordination is acceptable. If teams can change a tiny invariant together, share it deliberately. If they need to evolve separately, publish a language and treat its compatibility as production behavior. Hiding the coordination does not remove it; it only decides where the surprise will arrive.