0% прочитано

DDD in Practice in AVN Center: Boundaries, Aggregate Roots, and Consistency

Using ProductRegistry, I show several practical DDD decisions: how Domain, Application, Infrastructure, and Delivery are separated, why ProductRegistration and BootstrapInvitation have their own boundaries, where events fit, and where one strong-consistency transaction is the better model.

30 вересня 2026 р. 8 хв читанняLaravel

DDD is often explained through terms such as Entity, Value Object, Aggregate Root, Repository, and Domain Event.

In real code, I find another set of questions more useful: where does responsibility belong, who owns state, and which rules must remain consistent after an operation completes.

In AVN Center, this is clearly visible in ProductRegistry. The module deals with product registration, bootstrap authority, identity, persistence, an external API, and scenarios where several related changes must either succeed together or not become authoritative at all.

These decisions show how I use DDD not as a collection of patterns, but as a way to make responsibilities explicit in code.

This is not a complete DDD guide. I focus only on several ProductRegistry decisions where model boundaries directly affect the code.

Domain, Application, Infrastructure, and Delivery Change for Different Reasons

ProductRegistry responsibility boundaries
Delivery    HTTP contract    request / response mapping            |            vApplication    use case coordination    persistence contracts    policies            |            vDomain    state    identities    invariants    valid transitions

In ProductRegistry, I do not use these layers simply as a directory structure.

Domain owns model state and rules. This is where ProductRegistration, BootstrapInvitation, identities, and valid state transitions belong.

Application coordinates a use case. For example, EstablishOrResumeProductRegistration works through owner-defined repository and policy contracts rather than directly through an Eloquent model.

Infrastructure implements those contracts: persistence, database constraints, locking, and transaction mechanics.

Delivery owns the external boundary: HTTP contracts, request mapping, headers, status codes, and responses.

This gives me a simple rule: a business decision should not depend on how HTTP or persistence happens to be implemented today.

PHP - Application coordinates the use case through its own contracts
final readonly class EstablishOrResumeProductRegistration{    public function __construct(        private ProductRegistrationRepository $repository,        private ProductRegistrationIdGenerator $idGenerator,        private ProductRegistrationProtocolPolicy $protocolPolicy,    ) {}     public function execute(        ProductIdentity $productIdentity,        ProductInstanceIdentity $productInstanceIdentity,        BootstrapIdempotencyKey $bootstrapKey,        array $supportedProtocolVersions,    ): ProductRegistration {        $existing = $this->repository            ->findByBootstrapKey($bootstrapKey);

ProductRegistration and BootstrapInvitation Remain Separate Aggregate Roots

ProductRegistration and BootstrapInvitation participate in the same bootstrap flow, but that does not make them one Aggregate.

ProductRegistration owns the registration relationship and its lifecycle.

BootstrapInvitation has a different responsibility: it represents bounded bootstrap authority and has its own identity, lifetime, environment, binding, and reuse rules.

One object therefore does not become an internal part of another simply because they meet in the same Application use case.

For me, an Aggregate boundary is primarily defined by its own invariants and lifecycle, not by how close two objects appear in a flow.

PHP - BootstrapInvitation controls its own state transition
public function bind(    ProductRegistrationId $registrationId,    DateTimeImmutable $at,): void {    if ($this->boundRegistrationId !== null) {        if ($this->boundRegistrationId->equals($registrationId)) {            return;        }         throw BootstrapInvitationBindingConflict            ::alreadyBoundToAnotherRegistration();    }     if ($this->isRevoked()) {        throw BootstrapInvitationBindingNotAllowed::revoked();    }

External code should not simply assign a new bound_registration_id.

The bind() operation goes through the Aggregate Root itself, so it can enforce whether the invitation was already used, revoked, or expired.

This is one practical value of an Aggregate Root: state changes pass through the place that owns the corresponding rules.

An Event Belongs Where It Actually Separates Responsibilities

I do not introduce events automatically after every important Aggregate change.

An event is a good fit for a fact that has already happened and to which other parts of the system may react independently.

For example, after a successful authoritative operation, Audit, observability, projections, or other reactions can happen separately. A short delay does not make the original business operation incorrect.

But when the second step belongs to the same invariant, placing an event between them introduces an intermediate state that the system must be able to tolerate safely.

So the question for me is not "can I publish an event here?" but "can the second part of this work safely happen later?"

Events depend on the consistency requirement
Good fit for an event: authoritative state committed    |    +--> Audit    +--> observability    +--> projection A short delay is acceptable.  Different case: ProductRegistration established    |    +--> BootstrapInvitation binding still pending

Separate Aggregate Roots Can Still Require Strong Consistency

The bootstrap flow contains exactly this kind of case.

The first successful establishment or resume of a ProductRegistration together with binding the corresponding BootstrapInvitation forms one important consistency boundary.

I do not want a state where the registration is already authoritative while the invitation still appears free and could be used for a conflicting registration relationship.

Eventual consistency provides no advantage here. It would only create an intermediate state that would require additional protection, recovery, or compensation.

The layer responsibilities still remain separate: Application defines that the changes are semantically atomic, while Infrastructure implements the transaction, locking, and persistence guarantees.

Semantic boundary and technical transaction
Application decides the semantic operation: validate BootstrapInvitation        |        vestablish or resume ProductRegistration        |        vbind BootstrapInvitation        |        vsuccess  Infrastructure guarantees: 
One Aggregate Root per transaction remains a good default. It is not an absolute rule. When two Aggregate Roots participate in a real invariant that cannot be temporarily violated, strong consistency can be the correct choice.

What I Check When Defining a DDD Boundary

In practical work, I use several questions to test a boundary:

  • Who owns this state? The change should go through that owner rather than arbitrary external code.
  • Where does the invariant belong? If a rule belongs to one Aggregate, that Aggregate should protect it.
  • Do these two objects really share one lifecycle? A shared use case does not automatically make them one Aggregate.
  • Can the second reaction happen later? If yes, an event may be a good boundary.
  • Is the intermediate state acceptable? If not, strong consistency deserves serious consideration.
  • Is Infrastructure leaking into the model? Domain and Application should not depend on a concrete ORM or HTTP implementation.

For Me, DDD Is Primarily About Explicit Boundaries

In ProductRegistry, DDD does not mean that every class gets a Repository, every change produces an Event, or every related object belongs to one large Aggregate.

The value appears when boundaries remain as narrow as the real rules allow.

ProductRegistration and BootstrapInvitation can remain separate Aggregate Roots while still participating in one strong-consistency operation. Application can coordinate a use case without knowing Eloquent details. Infrastructure can own the transaction mechanics without making business decisions.

That is the practical side of DDD for me: not adding more architecture, but making responsibility explicit about where it belongs.

DDD helps me not by adding more patterns, but by making ownership of state, rules, and consistency boundaries more precise.