Architecture Overview

Version: 1.0.0

This document describes the high-level architecture of notifito, including service boundaries, design decisions, and data flow patterns.


System Context

notifito is a B2B2C micro-SaaS that connects sellers with consumers through a notification loop:

  1. Seller embeds widget on their website
  2. Consumer subscribes to unavailable items
  3. Seller signals availability via API or dashboard
  4. notifito matches and notifies interested consumers
graph TB subgraph "External Actors" SELLER[SellerManages catalog] CONSUMER[ConsumerSubscribes to items] SELLER_SERVER[Seller ServerPushes availability] end subgraph "notifito System" subgraph "Public Surface" WIDGET[WidgetJavaScript] EDGE[edgeAPI Gateway] end subgraph "Domain Logic" CORE[coreBusiness Logic] end subgraph "Delivery" DISPATCH[dispatchEmail Engine] end subgraph "Management" DASHBOARD[dashboardSeller UI] end end subgraph "External Services" POSTMARK[PostmarkEmail Provider] end SELLER -->|Configure| DASHBOARD CONSUMER -->|Subscribe| WIDGET SELLER_SERVER -->|Push| EDGE WIDGET -->|API| EDGE DASHBOARD -->|API| CORE EDGE -->|Events| CORE CORE -->|Match| CORE CORE -->|Notify| DISPATCH DISPATCH -->|Send| POSTMARK POSTMARK -->|Webhooks| EDGE

Service Boundaries

edge — Public API Surface

Responsibility: Absorb all public traffic, validate requests, enforce rate limits.

Concern Implementation
Widget API Schema fetch, subscription submit
Push API Availability ingestion
Authentication API key validation (publishable & secret)
Rate Limiting Per-key and per-IP limits
CORS Origin allowlist per tenant
Webhooks Provider webhook forwarding

Why separate: edge absorbs hostile traffic. It has a different failure mode (rate limiting, CORS) and scaling profile (high concurrency, low CPU) than core.

// apps/edge/app/Http/Controllers/WidgetController.php
class WidgetController
{
    public function schema(Request $request): JsonResponse
    {
        // Validates publishable key
        // Fetches schema from core
        // Returns cacheable response
    }

    public function subscribe(Request $request): JsonResponse
    {
        // Validates against schema
        // Forwards to core
        // Returns 202 Accepted
    }
}

core — Domain Logic

Responsibility: Own all business rules, data, and state.

Concern Implementation
Tenancy Tenant management, users, API keys
Catalog Item types, items, attribute schemas
Subscriptions Consumer subscriptions, consent ledger
Matching Two-stage attribute matching engine
Outbox Transactional event publishing

Why separate: core holds data that must not be lost. It has a different failure mode (database consistency) and scaling profile (CPU-intensive matching) than other services.

// apps/core/app/Domain/Matching/SubscriptionMatcher.php
class SubscriptionMatcher
{
    public function match(
        ItemType $itemType,
        array $eventAttributes,
        ?string $itemId = null,
    ): Collection {
        // Stage 1: SQL filter on tenant, item type, status
        // Stage 2: PHP attribute comparison
        return $candidates;
    }
}

dispatch — Delivery Engine

Responsibility: Send messages through channel drivers, track delivery state.

Concern Implementation
Channel Drivers Email (Postmark), future: SMS, WhatsApp, voice
Templates Per-tenant, per-channel message templates
Delivery State Two-phase lifecycle (queued → sending → accepted → delivered)
Webhooks Provider webhook processing
Retries Exponential backoff, dead-letter queue

Why separate: dispatch absorbs third-party provider failure. It has a different failure mode (provider outages) and scaling profile (I/O-bound) than other services.

// apps/dispatch/app/Domain/Channels/PostmarkEmailDriver.php
class PostmarkEmailDriver implements ChannelDriver
{
    public function send(OutboundMessage $message): SendResult
    {
        // Send via Postmark API
        // Return provider message ID
    }
}

dashboard — Seller UI

Responsibility: Provide web interface for sellers to manage their catalog.

Concern Implementation
Authentication Login via core API
Item Types CRUD with attribute schema editor
Notify Form Push availability via edge API
API Keys Display publishable key

Why separate: dashboard owns no domain database. It reaches core over HTTP, satisfying the requirement that frontend and backend be separate applications.

// apps/dashboard/app/Support/CoreClient.php
class CoreClient
{
    public function itemTypes(string $tenantId): array
    {
        return $this->request()
            ->get("/internal/tenants/{$tenantId}/item-types")
            ->json('item_types');
    }
}

widget — Consumer Form

Responsibility: Render subscription form inside seller's website.

Concern Implementation
Schema Fetch Load attribute schema from edge
Form Rendering Render inputs based on attribute types
Submission POST to edge API
Shadow DOM CSS isolation from seller's styles

Why separate: Widget executes in the consumer's browser. It must be a standalone JavaScript bundle (no framework) under 15KB gzipped.

// widget/src/index.js
class NotifitoWidget extends HTMLElement {
    connectedCallback() {
        this.attachShadow({ mode: 'open' });
        this.loadSchema();
    }

    async loadSchema() {
        const schema = await fetchSchema(this.key, this.itemType);
        this.renderForm(schema);
    }
}

Design Decisions

1. Monorepo, Not Polyrepo

Decision: One repository for all services.

Rationale: A polyrepo would require five coordinated pull requests to change one event payload. A monorepo keeps contract changes atomic while the applications remain independently deployable.

2. One Database Per Service

Decision: Each service owns its own PostgreSQL database. No cross-database queries.

Rationale: Cheap to operate now. Each database can be lifted onto its own server later without a code change. Enforces clean service boundaries.

3. Asynchronous by Default

Decision: Services communicate via event bus (Redis queues) unless a human is waiting.

Rationale: Decouples services, enables independent scaling, provides natural retry semantics. Synchronous HTTP only where required (widget submit, dashboard API).

4. Transactional Outbox

Decision: Domain changes and outbound events commit in the same transaction.

Rationale: Without it, events are lost in the window between commit and publish. The outbox is roughly 100 lines and is the difference between "usually notifies" and "notifies".

5. Two-Stage Matching

Decision: SQL filter for narrowing, PHP for type-specific comparison.

Rationale: A single query expressing date-range overlap inside JSONB is possible and unreadable. The candidate set after stage one is already small. When a tenant outgrows this, materialized columns can be added on evidence.

6. Double Opt-in Everywhere

Decision: Every subscription requires email confirmation, regardless of jurisdiction.

Rationale: Strictest common denominator. Eliminates the need for per-region consent rules in v1. The RegionalPolicy seam exists for future relaxation.

7. Closed Attribute Type Set

Decision: Exactly seven types: enum, integer, decimal, date, date_range, boolean, text.

Rationale: Three consumers share this contract: widget renderer, server-side validation, matching engine. Adding an eighth type requires teaching all three about it. The set does not grow.


Data Flow

Subscription Flow

sequenceDiagram participant C as Consumer participant W as Widget participant E as edge participant CO as core participant D as dispatch participant P as Postmark C->>W: Fill form W->>E: POST /v1/widget/subscriptions E->>CO: Create subscription CO->>CO: Create consumer, contact point CO->>CO: Append consent event CO->>D: ConfirmationRequested D->>P: Send confirmation email E-->>W: 202 Accepted W-->>C: "Check your email" C->>P: Click confirmation link P->>E: GET /c/{token} E->>CO: Confirm subscription CO->>CO: Verify contact point CO->>CO: Activate subscription CO->>CO: Append consent event E-->>C: "Confirmed!"

Availability Flow

sequenceDiagram participant S as Seller participant E as edge participant CO as core participant D as dispatch participant P as Postmark S->>E: POST /v1/availability E->>CO: AvailabilityEventReceived CO->>CO: Store event (idempotent) CO->>CO: Match subscriptions CO->>D: SubscriptionMatched D->>D: Create delivery records D->>P: Send notification emails P-->>D: Webhook (delivered) D->>CO: DeliveryStatusChanged

Security Model

API Key Types

Key Type Prefix Location Purpose
Publishable pk_ Widget page source Widget authentication
Secret sk_ Server only Push API, dashboard

Defense Layers

  1. Rate Limiting: Per-key and per-IP limits on all public endpoints
  2. Origin Allowlist: Widget requests checked against tenant's registered domains
  3. Internal Secret: Service-to-service calls authenticated with shared secret
  4. Token Expiry: Confirmation tokens expire after 72 hours
  5. Idempotency Keys: Prevent duplicate notifications from retries

Scaling Considerations

Current Architecture

Future Scaling

Bottleneck Solution
Matching CPU Horizontal scale core workers
Queue throughput Scale Horizon workers per queue
Database load Read replicas, connection pooling
Email delivery Multiple Postmark accounts

Related Documentation