Skip to content
SaaS4Builders
Backend

Domain Layer

Framework-agnostic business logic: contracts, DTOs, enums, value objects, domain rules, services, and exceptions - everything that defines how your SaaS actually works.

The Domain layer is where your core business logic lives.

It is designed to stay independent from Laravel, Eloquent, and HTTP concerns, so your business rules remain easy to test, reason about, and evolve over time.

This layer models the essential concepts of your SaaS — pricing rules, permissions, billing invariants — without being tied to any specific framework or infrastructure.

backend/app/Domain/

Directory Structure

The Domain layer is organized by business domain. Each domain contains the same set of building blocks:

Domain/
├── Auth/
│   ├── Contracts/             # OAuthProvider interface
│   └── DTO/                   # OAuthUser transfer DTO
├── Billing/
│   ├── Contracts/             # PaymentGatewayInterface, InvoicingStrategyInterface, ...
│   ├── DTO/                   # CheckoutSession, TaxCalculation, InvoiceDraft, ...
│   ├── Enums/                 # PricingType, SubscriptionStatus, BillingMode, ...
│   ├── Exceptions/            # AlreadySubscribedException, BillingNotReadyException, ...
│   ├── Services/              # BillingModeResolver, CurrencyResolver, ...
│   └── ValueObjects/          # BillingInterval, InvoicePdfSource
├── Catalog/
│   ├── Enums/                 # EntitlementType
│   ├── Exceptions/            # SystemFeatureImmutableException
│   └── Rules/                 # CanAccessFeature
├── Shared/
│   └── ValueObjects/          # Money
├── Team/
│   ├── Contracts/             # InvitationNotifierInterface
│   ├── Enums/                 # InvitationStatus, TeamRole
│   ├── Exceptions/            # TeamException
│   ├── Support/               # ResolvesTeamRole, TeamContext
│   └── ValueObjects/          # InvitationToken
└── Usage/
    ├── Contracts/             # ReconciliationServiceInterface, UsageReporterInterface
    ├── Enums/                 # AggregationType, QuotaEnforcement, ResetInterval
    ├── Exceptions/            # QuotaExceededException, DuplicateUsageEventException
    ├── Services/              # QuotaChecker, UsageCalculator, ...
    └── ValueObjects/          # UsagePeriod

Why Keep Domain Rules Framework-Agnostic

Domain rules, value objects, enums, and contracts never depend on Laravel facades, Eloquent, or HTTP concerns. This keeps business logic fast to test, easy to reason about, and isolated from infrastructure choices.

In practice, orchestration that needs Eloquent models belongs in the Application layer, which may pass normalized data into the Domain when needed.

Domain code remains largely framework-agnostic. This gives you three benefits:

  1. Testability — You can test business rules without bootstrapping the framework. A pure enum or value object test runs in milliseconds.
  2. Portability — Domain logic is not locked to Laravel. If you ever swap frameworks (unlikely, but possible), this layer moves with you unchanged.
  3. Separation — Business rules live in one place. You never wonder whether a pricing calculation is buried in a controller, a middleware, or a model.
Practical note: In some cases, Domain Services may receive Eloquent models as parameters (e.g., BillingReadinessChecker::isReady(Tenant $tenant)). This is a pragmatic trade-off to avoid unnecessary mapping layers. However, orchestration and data retrieval remain the responsibility of the Application layer.

Contracts (Interfaces)

Contracts define ports — the boundaries between your application and external systems. The Domain declares what it needs; the Infrastructure layer provides the implementations.

Here is the payment gateway contract. Every billing Action depends on this interface, never on Stripe directly:

backend/app/Domain/Billing/Contracts/PaymentGatewayInterface.php
interface PaymentGatewayInterface
{
    /**
     * Create or retrieve a customer in the payment provider.
     *
     * @return string The provider's customer ID
     */
    public function ensureCustomer(Tenant $tenant): string;

    /**
     * Create a checkout session for subscription signup.
     */
    public function createCheckoutSession(CreateCheckoutData $data): CheckoutSession;

    /**
     * Create a subscription in the provider.
     *
     * @return array<string, mixed> Provider subscription data
     */
    public function createSubscription(
        string $customerId,
        string $priceId,
        array $options = []
    ): array;

    /**
     * Cancel a subscription.
     *
     * @param bool $immediately If false, cancel at period end
     */
    public function cancelSubscription(
        string $subscriptionId,
        bool $immediately = false
    ): void;

    /**
     * Resume a subscription that was scheduled for cancellation.
     */
    public function resumeSubscription(string $subscriptionId): void;

    /**
     * Update subscription (change plan, quantity).
     *
     * @return array<string, mixed> Updated subscription data
     */
    public function updateSubscription(
        string $subscriptionId,
        array $params
    ): array;

    /**
     * Get the billing portal URL for self-service management.
     */
    public function getBillingPortalUrl(string $customerId, string $returnUrl): string;
}

Not every contract is this large. Some are simple single-method interfaces:

backend/app/Domain/Team/Contracts/InvitationNotifierInterface.php
interface InvitationNotifierInterface
{
    /**
     * Send an invitation notification to the invitee.
     */
    public function sendInvitation(Invitation $invitation, string $plainToken): void;
}

The pattern is the same: the Domain declares what it needs, Infrastructure provides how.


DTOs (Transfer Objects)

Domain DTOs represent normalized business data. They flow from Infrastructure mappers back through Actions. They are provider-agnostic — no stripe_* fields, no SDK-specific types.

backend/app/Domain/Billing/DTO/CheckoutSession.php
final readonly class CheckoutSession
{
    public function __construct(
        public string $sessionId,
        public string $url,
    ) {}
}

Whether the checkout session came from Stripe, Paddle, or a test fake, it looks the same to the Application layer.

Domain DTOs represent data from external systems. Application Input DTOs (covered in Application Layer) represent data to use cases. The distinction matters: Domain DTOs are provider-agnostic; Input DTOs are HTTP-agnostic.

Enums

Enums are not just value labels — they carry business behavior. Methods on enums encode rules that the rest of the codebase can rely on:

backend/app/Domain/Billing/Enums/PricingType.php
enum PricingType: string
{
    case Flat = 'flat';
    case Seat = 'seat';
    case Usage = 'usage';

    public function supportsQuantity(): bool
    {
        return $this === self::Seat;
    }

    public function requiresUsageData(): bool
    {
        return $this === self::Usage;
    }
}

The supportsQuantity() method is used by billing calculations to determine whether a plan's price depends on team size. Instead of scattering if ($plan->pricing_type === 'seat') checks throughout the codebase, the knowledge lives in one place.

Here is another example with richer behavior:

backend/app/Domain/Billing/Enums/SubscriptionStatus.php
enum SubscriptionStatus: string
{
    case Active = 'active';
    case Trialing = 'trialing';
    case PastDue = 'past_due';
    case Canceled = 'canceled';
    case Unpaid = 'unpaid';
    case Paused = 'paused';
    case Incomplete = 'incomplete';
    case IncompleteExpired = 'incomplete_expired';

    public function isActive(): bool
    {
        return in_array($this, [self::Active, self::Trialing], true);
    }

    public function canUpgrade(): bool
    {
        return in_array($this, [self::Active, self::Trialing, self::PastDue], true);
    }

    public function canCancel(): bool
    {
        return in_array($this, [self::Active, self::Trialing, self::PastDue], true);
    }
}

When an Action needs to know whether a subscription can be upgraded, it calls $subscription->status->canUpgrade() — no manual status comparisons.


Value Objects

Value objects are immutable objects that represent a concept with value semantics. Two Money objects with the same amount and currency are equal, regardless of where they were created.

The Money value object is the most used one in the codebase. It enforces currency consistency across all arithmetic operations:

backend/app/Domain/Shared/ValueObjects/Money.php
final readonly class Money implements \JsonSerializable
{
    private function __construct(
        public int $amount,
        public string $currency,
    ) {}

    // Factory methods
    public static function cents(int $amount, string $currency): self
    {
        return new self($amount, strtoupper($currency));
    }

    public static function zero(string $currency): self
    {
        return new self(0, strtoupper($currency));
    }

    public static function fromMajor(float $amount, string $currency): self
    {
        $minorUnits = CurrencyRegistry::minorUnits($currency);
        $cents = (int) round($amount * (10 ** $minorUnits));
        return new self($cents, strtoupper($currency));
    }

    // Arithmetic with currency guards
    public function add(Money $other): self
    {
        $this->assertSameCurrency($other);
        return new self($this->amount + $other->amount, $this->currency);
    }

    public function subtract(Money $other): self
    {
        $this->assertSameCurrency($other);
        return new self($this->amount - $other->amount, $this->currency);
    }

    public function multiply(int|float $factor): self
    {
        return new self((int) round($this->amount * $factor), $this->currency);
    }

    // Comparison and formatting methods omitted for brevity

    // Serialization (CONTRACTS.md §1.4)
    public function jsonSerialize(): array
    {
        return [
            'amount' => $this->amount,
            'currency' => $this->currency,
        ];
    }

    private function assertSameCurrency(Money $other): void
    {
        if ($this->currency !== $other->currency) {
            throw CurrencyMismatchException::forOperation(
                $this->currency,
                $other->currency
            );
        }
    }
}

Attempting to add EUR to USD throws a CurrencyMismatchException. This prevents a class of bugs that would otherwise surface only in production.

A simpler value object — BillingInterval validates its invariant at construction time:

backend/app/Domain/Billing/ValueObjects/BillingInterval.php
final readonly class BillingInterval
{
    public function __construct(
        public IntervalUnit $unit,
        public int $count,
    ) {
        if ($count < 1) {
            throw new InvalidArgumentException('Interval count must be at least 1');
        }
    }

    public function toArray(): array
    {
        return [
            'unit' => $this->unit->value,
            'count' => $this->count,
        ];
    }
}

Rules

A Rule encapsulates a reusable business condition. Rules depend only on ValueObjects, Enums, or primitives — never on Eloquent, repositories, or providers.

backend/app/Domain/Catalog/Rules/CanAccessFeature.php
final readonly class CanAccessFeature
{
    /**
     * Check if access to a feature is allowed.
     *
     * @param EntitlementType $type   The type of entitlement (boolean or quota)
     * @param int|null        $value  The quota limit (null = unlimited)
     * @param int|null        $currentUsage  Current usage count
     */
    public function __invoke(
        EntitlementType $type,
        ?int $value,
        ?int $currentUsage
    ): bool {
        return match ($type) {
            EntitlementType::Boolean => true,
            EntitlementType::Quota => $this->checkQuota($value, $currentUsage),
        };
    }

    private function checkQuota(?int $limit, ?int $usage): bool
    {
        if ($limit === null) {
            return true; // Unlimited
        }
        return ($usage ?? 0) < $limit;
    }
}

This rule is pure logic. It does not query the database or call any service — it just answers the question "given this entitlement type and these numbers, is access allowed?"

Rule vs Policy

CharacteristicRulePolicy
LocationDomain/<Domain>/Rules/app/Policies/
DependenciesValueObjects, Enums, primitivesUser, Model (Eloquent)
PurposePure business conditionAuthorization check
FrameworkNoneLaravel Gate/Policy
ExampleCanAccessFeatureSubscriptionPolicy

When to choose: if the check needs database data ("is this user authorized to cancel?"), use a Policy. If it is pure logic on values ("does this entitlement type allow access given current usage?"), use a Rule.


Services

Domain Services coordinate multiple Domain concepts. They resolve state, compute results, or apply business logic that does not belong to a single entity or value object.

backend/app/Domain/Billing/Services/BillingModeResolver.php
final class BillingModeResolver
{
    /**
     * Get the current billing mode from configuration.
     */
    public function current(): BillingMode
    {
        $configuredMode = config('billing.mode');

        if ($configuredMode === null) {
            return BillingMode::default();
        }

        $mode = BillingMode::tryFrom($configuredMode);

        return $mode ?? BillingMode::default();
    }

    /**
     * Get the current billing mode, throwing if unsupported.
     *
     * @throws UnsupportedBillingModeException
     */
    public function currentOrFail(): BillingMode
    {
        $mode = $this->current();

        if (! $mode->isSupported()) {
            throw UnsupportedBillingModeException::forMode($mode);
        }

        return $mode;
    }

    public function isPlatformManaged(): bool
    {
        return $this->current()->isPlatformManaged();
    }
}

The BillingModeResolver is used by the Infrastructure layer's strategy pattern to determine which invoicing strategy to use.


Exceptions

Domain exceptions represent business constraint violations. They extend DomainException and use static factory methods for clarity:

backend/app/Domain/Billing/Exceptions/AlreadySubscribedException.php
final class AlreadySubscribedException extends \DomainException
{
    public function __construct(
        public readonly string $tenantId,
        public readonly string $planId,
        string $message = '',
    ) {
        parent::__construct($message ?: 'Tenant is already subscribed to this plan');
    }

    public static function forPlan(string $tenantId, string $planId): self
    {
        return new self(
            $tenantId,
            $planId,
            "Tenant {$tenantId} is already subscribed to plan {$planId}"
        );
    }
}

The static factory pattern (AlreadySubscribedException::forPlan(...)) makes exception creation self-documenting. The readonly properties allow controllers to extract structured data for API error responses.

The boilerplate ships with domain exceptions for every business constraint: BillingNotReadyException, CurrencyMismatchException, QuotaExceededException, PlanNotEligibleException, and more. Each carries the context needed to produce a meaningful API error response.


Summary: What Goes in Domain

Building BlockPurposeCount in Codebase
ContractsInterfaces for external services~10
DTOsProvider-agnostic transfer data~25
EnumsTyped states with business behavior~12
ValueObjectsImmutable concepts (Money, Interval, Token)~5
RulesPure business conditions1+
ServicesMulti-concept coordination~19
ExceptionsBusiness constraint violations~20

What's Next