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:
- Seller embeds widget on their website
- Consumer subscribes to unavailable items
- Seller signals availability via API or dashboard
- notifito matches and notifies interested consumers
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
Availability Flow
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
- Rate Limiting: Per-key and per-IP limits on all public endpoints
- Origin Allowlist: Widget requests checked against tenant's registered domains
- Internal Secret: Service-to-service calls authenticated with shared secret
- Token Expiry: Confirmation tokens expire after 72 hours
- Idempotency Keys: Prevent duplicate notifications from retries
Scaling Considerations
Current Architecture
- Single VPS deployment via Docker Compose
- One container per service
- Horizon for queue workers
- Caddy as reverse proxy
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
- Data Model — Database schema and relationships
- Communication — Event bus and queue configuration
- Configuration Reference — Environment variables