Skip to content

Domain Design & Architecture / Domain-Driven Design

Domain-Driven Design in Semitexa: Make Business Decisions Explicit

By ·

“May this order leave the warehouse?” brings payment, fulfilment, concurrency, and communication into one operation. Follow a verified example through Semitexa to see how a business decision gets a clear owner, an explicit contract, and an executable path.

Begin with a decision the business recognizes

An operator presses “Dispatch order.” The application already has the order’s address, line items, and current status. Those fields do not answer the question that matters: may this order leave the warehouse? Settlement may still be pending. A fraud hold may have appeared after payment. Another operator may already have completed the same workflow.

A useful model gives those situations names and assigns each decision an owner. Fulfilment owns readiness for dispatch. Payments supplies a settlement assessment. The application coordinates the operation. Persistence decides whether the proposed update still matches the stored version. Notifications follow an accepted change.

This is where Domain-Driven Design becomes practical in Semitexa. The framework supplies module structure, contracts, resource-to-domain mapping, and executable workflow definitions. Those mechanisms let a business decision remain visible as the application grows. The model itself still comes from understanding the business.

The DDD Community’s introduction connects implementation to an evolving model of core business concepts, developed with domain experts. For our example, that collaboration must settle what “settled,” “fraud hold,” and “dispatched” mean before PHP can enforce them.

We will use a small fulfilment example written for this article and execute it through Semitexa’s installed Workflow engine. The business policy is illustrative application code; the transition handling, structured results, and repository contracts are existing framework mechanisms.

Give each model a clear vocabulary and owner

Consider how different teams use the word “order.” Fulfilment needs a destination, packing state, and permission to dispatch. Payments needs amounts, settlement, and refunds. Customer support needs a customer conversation and promised outcome. A single object containing every concern makes each team depend on decisions belonging to the others.

A bounded context gives a model a defined meaning and makes its relationships with other models explicit. That is the strategic role described in Martin Fowler’s Bounded Context explanation. In our proposed design, Payments and Fulfilment communicate through a settlement assessment, while retaining their own models.

ContextOwnsPublishes or consumes
PaymentsWhat counts as settled funds and a payment holdA settlement assessment for an order
FulfilmentDispatch policy and fulfilment lifecycleA named dispatch command and its result
Customer communicationMessage format and delivery handlingAn accepted dispatch outcome

These are proposed business boundaries for the example. Semitexa modules are a way to organize their implementation. The correspondence needs a design decision: a package or a Domain/ directory does not establish a bounded context by itself. Several modules may serve one context, and two contexts may run in one PHP process.

The same distinction applies to tenants. A tenant identifies whose work is running; a bounded context identifies which model and vocabulary give that work meaning. A multi-tenant application can contain several business contexts without turning every context into a separate service.

Make the business decision independent of the workflow engine

Our example starts with a small immutable assessment. It uses ordinary PHP and has no ORM attributes, SQL, HTTP request, or workflow dependency:

final readonly class SettlementSnapshot
{
    public function __construct(public bool $settled, public bool $fraudHold) {}
    public function permitsDispatch(): bool { return $this->settled && !$this->fraudHold; }
}

permitsDispatch() expresses the chosen rule: funds must be settled and the assessment must have no fraud hold. A domain expert can read that condition and challenge it. If the business later permits dispatch against an approved credit agreement, this is the place to discuss and change the decision.

The snapshot is a value carrying an assessment. It has no independent lifecycle or identity, which makes it different from an order entity. It is deliberately small: it does not attempt to reconstruct the entire Payments model inside Fulfilment.

A production boundary would also define assessment freshness, currency or amount requirements where relevant, and the authority allowed to produce it. Those requirements belong in the model and its contract as the domain demands them. Two booleans are sufficient to exercise this example’s rule; they are not a complete payment protocol.

For entity behavior and storage representation, our earlier domain-model article follows the real MachineCredential class through an immutable operation and a mapper. Here we widen the view to decisions involving several responsibilities.

Translate between contexts through an explicit contract

The example’s SettlementReader promises forOrder(string $orderId): SettlementSnapshot. Fulfilment asks for the assessment it needs. Its caller does not have to understand a payment provider’s response fields or database schema.

A production adapter can translate the payment system’s vocabulary into this contract. That translation is a natural place to isolate external terminology and interpretation. The adapter must know whether a provider’s “authorized” response satisfies the business’s definition of settled funds; copying a status string is insufficient.

Semitexa’s contract mechanism supports explicit implementation declarations. The installed Workflow engine advertises its engine interface, and its database repository advertises its repository interface:

#[SatisfiesServiceContract(of: WorkflowEngineInterface::class)]
final class WorkflowEngine implements WorkflowEngineInterface

#[SatisfiesRepositoryContract(of: WorkflowInstanceRepositoryInterface::class)]
final class WorkflowInstanceRepository implements WorkflowInstanceRepositoryInterface

These are declaration excerpts from existing classes. Their consumers depend on interfaces. Attribute injection uses #[InjectAsReadonly] on typed properties; bin/semitexa contracts:list shows the active implementations. See the service-contract documentation for discovery and resolution.

Our evidence script supplies the illustrative settlement reader directly through an in-memory container. It tests the decision and engine interaction, rather than pretending to validate automatic discovery or production service wiring. An application module would provide the reader and guard through its normal container setup.

Describe the lifecycle in business language

A named transition makes the intended operation visible. Here is the complete illustrative workflow definition used by the evidence script:

#[AsWorkflowDefinition]
final class FulfilmentDefinition implements WorkflowDefinitionInterface
{
    public static function key(): string { return 'fulfilment'; }
    public function initialState(): string { return 'ready'; }
    public function states(): array { return ['ready', 'shipped']; }
    public function terminalStates(): array { return ['shipped']; }
    public function transitions(): array
    {
        return [new TransitionDefinition(
            key: 'dispatch', fromStates: ['ready'], toState: 'shipped',
            guards: [FundsSettledGuard::class], sideEffects: [NotifyDispatch::class],
        )];
    }
}

The installed WorkflowDefinitionRegistry discovers attributed definitions implementing WorkflowDefinitionInterface. The example harness installs this definition explicitly to keep the verification isolated.

dispatch is valid from ready and moves the workflow to shipped. The definition associates that operation with a guard and a notification side effect. A reviewer can inspect the lifecycle without searching through request handling and database writes.

The guard bridges the framework contract to the business assessment:

final class FundsSettledGuard implements WorkflowGuardInterface
{
    public function __construct(private SettlementReader $settlements) {}
    public function evaluate(
        WorkflowSubjectReferenceInterface $subject,
        WorkflowInstance $instance,
        TransitionDefinition $transition,
        array $context,
    ): GuardResult {
        $settlement = $this->settlements->forOrder($subject->workflowSubjectId());
        return $settlement->permitsDispatch()
            ? GuardResult::pass()
            : GuardResult::deny('dispatch_not_permitted', 'Settlement does not permit dispatch.');
    }
}

The guard loads the assessment through SettlementReader. It deliberately does not trust a settled flag in command context. In the executable check, an unsettled order is denied even when the caller supplies ['settled' => true].

This division keeps the decision in SettlementSnapshot, the retrieval promise in SettlementReader, and the workflow integration in FundsSettledGuard. Changing storage or transport does not require rewriting the settlement predicate.

Follow one command through the application boundary

After the application has authenticated the actor, checked access to the subject, and selected the workflow, it can request the transition through WorkflowEngineInterface. The following excerpt assumes that authorized application boundary and an injected engine:

$result = $engine->apply(new ApplyTransitionCommand(
    workflowKey: 'fulfilment',
    instanceId: $workflowId,
    transitionKey: 'dispatch',
));

The engine looks up the definition and instance, rejects terminal status, finds a transition matching the requested key and current state, and evaluates its guards. A denied guard returns a structured failure and records a rejected attempt. An accepted decision proceeds to the version-checked write.

Authorized application command
    → workflow definition + current instance
    → transition eligibility
    → settlement assessment + domain decision
    → version-checked state write
    → history and follow-up effects
    → structured application result

The transport can turn that result into HTML feedback, an API response, or a job outcome. Business rejection already has a code and meaning before the presentation is chosen. guard_denied, invalid_transition, and version_conflict identify different causes and should lead to different application responses.

The installed engine’s WorkflowInstance is mutable and exposes setters. Its lifecycle rules are enforced along the engine, definition, and guard path. Directly calling a setter bypasses that path. This example demonstrates explicit policy and orchestration; it does not claim that this generic instance is an encapsulated rich aggregate.

For a business aggregate, choose the state and invariants that must change together and expose operations that preserve them. A reusable workflow record can track that process, while the application’s order model continues to own order-specific behavior.

Design persistence and delivery around the decision

A guard can approve an operation against state that another request is about to change. The Workflow repository addresses concurrent updates with an expected version. Its SQL update includes this condition:

WHERE id = :id AND version = :expected_version

The repository returns whether a row was updated. The engine reports version_conflict when the write loses. Our in-memory adapter exercises that branch with independent snapshots: the stored workflow remains ready and the notification is not called. We inspected the real repository’s SQL; this article’s harness does not execute MySQL or simulate simultaneous database requests.

Version checking protects this workflow record. It does not reserve stock, lock a payment ledger, or make several aggregates change atomically. If those facts must be consistent at dispatch time, the application needs an explicit consistency strategy for them.

There is also a distinction between saving the transition and delivering its consequences. The inspected engine saves the version-checked state, then writes history separately. Although it declares a transaction-manager dependency, that path does not wrap both writes in an encompassing transaction. A requirement for atomic state and history needs that boundary implemented and verified.

Side effects run after a successful save. Our synthetic notification failure leaves the transition applied. That behavior avoids undoing a business decision merely because a notification endpoint is unavailable, but reliable eventual delivery still needs a durable handoff and retry strategy. A transactional outbox is one possible application design; this example does not demonstrate one.

The command has an idempotencyKey field, but the inspected apply path does not consume it for deduplication. Treat idempotent side effects as an implementation responsibility. The terminal-state check prevents repetition in this example’s terminal workflow; it is not a general delivery guarantee.

These are useful design questions precisely because the boundaries are visible. You can identify which promise belongs to a policy, a repository, a transaction, or a delivery adapter instead of letting one “success” response conceal all of them.

Inspect the results, then apply the same method to your domain

The retained script runs the installed WorkflowEngine with the illustrative definition, settlement rule, explicit container, and independent in-memory repository adapters. Its seven scenarios passed 18 assertions:

ScenarioObserved result
Funds not settledGuard rejection; unchanged stored state
Settled funds with fraud holdGuard rejection; no notification
Settled funds without holdApplied; shipped, completed, version 1
Another attempt after completionTerminal-state rejection; no repeated notification
Version-checked save losesConflict; no applied history or notification
Unknown transition keyInvalid-transition rejection
Notification returns a failureState remains applied; notification attempted after save

No project database, real payment provider, scheduler, or external message was touched. Event delivery and production DI discovery were outside the harness. The retained verification records separate those limits from the behavior that actually executed.

Implementation and evidence trail
  • semitexa/workflow: WorkflowEngine.php, WorkflowDefinitionRegistry.php, the definition, guard, side-effect and repository interfaces, and WorkflowInstanceRepository.php.
  • Runnable application example: var/docs/ddd-business-boundaries-example.php.
  • Observed outcomes and implementation limits: var/docs/ddd-business-boundaries-evidence.json.

To use this approach in your own module, choose an operation a domain expert names, agree on the facts that permit it, and assign each fact an owner. Define the contract that crosses the boundary, put the decision in code that can be exercised independently, then verify the orchestration and consistency promises around it.

Semitexa gives that work a concrete structure: typed contracts, discoverable implementations, separate persistence models, and a workflow path whose decisions can be inspected. The payoff is a business rule that remains recognizable from the conversation with a domain expert through to the application result.

Explore the architecture

Give the next business decision a clear boundary.

Inspect the contracts that connect your modules, then follow a domain model through its persistence boundary.

Have a product idea?One free MVP every month