Skip to content
SaaS4Builders
Architecture

Architecture Overview

The mental model, request flow, layer structure, and decision guide for the SaaS4Builders backend architecture.

SaaS4Builders uses a domain-driven, layered architecture on the backend and a vertical-slice architecture on the frontend. This page gives you the mental model you need to know where your code goes and how requests flow through the system.


The 30-Second Mental Model

Every API request follows the same pipeline:

HTTP → Request → Input DTO → Action / Query → Domain → Model → Resource → JSON

Four principles govern the flow:

  • Controllers delegate — they contain no business logic
  • Actions mutate — wrapped in a database transaction
  • Queries read — no side effects, ever
  • Resources define the API contract — stable, versioned, snake_case output

If you remember nothing else, remember this: controllers receive, actions do, resources respond.


Layer Diagram

The backend is organized into distinct layers, each with a clear responsibility:

backend/app/
├── Application/     # Use cases (Actions, Queries, Input DTOs)
├── Domain/          # Pure business logic (Enums, ValueObjects, Contracts, Rules)
├── Infrastructure/  # External integrations (Providers, Mappers)
├── Http/            # API layer (Controllers, Requests, Resources)
├── Models/          # Eloquent models
├── Policies/        # Authorization policies
└── Support/         # Helpers, traits, cross-cutting concerns

Each layer has strict dependency rules:

  • Domain depends on nothing (no Laravel, no Eloquent, no HTTP)
  • Application depends on Domain (uses contracts, enums, value objects)
  • Infrastructure depends on Domain (implements its contracts)
  • Http depends on Application (calls actions and queries)

How a Request Flows Through the System

Let's trace a real request — creating a subscription via Stripe Checkout — through every layer.

Step 1: HTTP Validation and DTO Mapping

The request arrives at a FormRequest. It validates the input, authorizes the user, and maps the validated data to an Input DTO via the toDto() method:

backend/app/Http/Requests/Api/V1/Tenant/CreateCheckoutRequest.php
final class CreateCheckoutRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true; // Authorization handled by tenant.member middleware
    }

    public function rules(): array
    {
        return [
            'plan_id' => ['required', 'uuid', 'exists:plans,id'],
            'currency' => ['required', 'string', 'size:3', 'exists:currencies,code'],
            'success_url' => ['required', 'url'],
            'cancel_url' => ['required', 'url'],
            'quantity' => ['nullable', 'integer', 'min:1'],
        ];
    }

    public function toDto(Tenant $tenant): CreateSubscriptionData
    {
        return new CreateSubscriptionData(
            tenant: $tenant,
            plan: Plan::findOrFail($this->validated('plan_id')),
            currency: $this->validated('currency'),
            successUrl: $this->validated('success_url'),
            cancelUrl: $this->validated('cancel_url'),
            quantity: $this->integer('quantity', 1),
        );
    }
}

Every FormRequest must implement toDto(). This is the only place where HTTP input is mapped to application-level data.

Step 2: Action Executes Business Logic

The controller passes the DTO to an Action. The Action orchestrates business rules, calls Domain services, and delegates external operations to contracts (interfaces):

backend/app/Application/Billing/Actions/CreateSubscription.php
final class CreateSubscription
{
    public function __construct(
        private PaymentGatewayInterface $gateway,  // Domain contract, not Stripe
        private BillingReadinessChecker $checker,
    ) {}

    public function viaCheckout(CreateSubscriptionData $data): CheckoutSession
    {
        // 1. Validate business rules (readiness, configuration, uniqueness)
        // 2. Resolve PlanPrice for the requested currency
        // 3. Normalize seat quantity for seat-based plans
        // 4. Delegate to the payment gateway via the Domain contract
        return $this->gateway->createCheckoutSession($checkoutData);
    }
}

The Action depends on PaymentGatewayInterface (a Domain contract), not on StripePaymentGateway directly. This is how the architecture stays provider-agnostic. See the full Action code for all validation steps.

Step 3: Infrastructure Handles External Calls

The StripePaymentGateway implements PaymentGatewayInterface. It calls the Stripe SDK and returns Domain DTOs — the Application layer never sees Stripe-specific types.

Step 4: Controller Returns the Response

The controller wraps the result in a Resource and returns JSON:

backend/app/Http/Controllers/Api/V1/Tenant/SubscriptionController.php
public function show(string $tenantId, GetSubscription $query): JsonResponse
{
    $subscription = $query->execute($tenantId);

    if ($subscription === null) {
        return response()->json(['data' => null]);
    }

    return response()->json([
        'data' => new SubscriptionResource($subscription),
    ]);
}

Controllers are thin. They receive the request, call an Action or Query, and return a Resource. That is all.

Full Flow Diagram

flowchart TD
    A["HTTP — Request<br/>authorize() → Policy check<br/>rules() → Validation<br/>toDto() → CreateSubscriptionData"] --> B["APPLICATION — CreateSubscription Action<br/>Inject: PaymentGateway interface<br/>Validate business rules<br/>Call gateway.createCheckoutSession()<br/>Return CheckoutSession DTO"]
    B --> C["INFRASTRUCTURE — StripePaymentGateway<br/>Calls Stripe SDK<br/>Returns CheckoutSession Domain DTO"]
    C --> D["HTTP — Controller<br/>return JSON response with checkout URL"]

Where Does My Code Go?

I want to...I put it in...Example
Create, update, or delete dataApplication/<Domain>/Actions/CreateSubscription
List, filter, search, or paginateApplication/<Domain>/Queries/ListInvoices
Define input data for a use caseApplication/<Domain>/DTO/CreateSubscriptionData
Validate HTTP inputHttp/Requests/Api/V1/<Context>/CreateCheckoutRequest
Format API responseHttp/Resources/Api/V1/<Context>/SubscriptionResource
Define a business contract (interface)Domain/<Domain>/Contracts/PaymentGatewayInterface
Create an enum or value objectDomain/<Domain>/Enums/ or ValueObjects/PricingType, Money
Write a reusable business ruleDomain/<Domain>/Rules/CanAccessFeature
Integrate an external serviceInfrastructure/<Domain>/Providers/<Provider>/StripePaymentGateway
Map external data to domain typesInfrastructure/<Domain>/Mappers/TaxBreakdownMapper
Write feature teststests/Feature/Api/V1/...SubscriptionTest
Write unit teststests/Unit/...CreateSubscriptionTest

Action vs Query

QuestionActionQuery
Mutates state?YesNo
Uses DB::transaction()?YesNo
Returns model?UsuallySometimes (paginator, collection)
Side effects allowed?Yes (events, external calls)Never
Naming conventionCreateX, CancelX, UpdateXListX, FindX, GetX

The rule: if it changes anything — database, external service, file system — it is an Action.


Frontend Architecture at a Glance

The frontend uses a vertical-slice architecture. Each feature is a self-contained module with its own types, schemas, API client, composables, components, stores, and pages:

frontend/features/
├── foundation/    # Auth, tenancy, settings — always loaded
├── core/          # Billing, team, usage — core SaaS features
└── product/       # Your custom product features go here
Frontend architecture is covered in detail in Vertical Slices, Composables & Stores, and Validation.

What's Next

Dive into each backend layer: