Communication Architecture
Version: 1.0.0
This document describes how notifito services communicate, including the event bus abstraction, queue configuration, and the transactional outbox pattern.
Overview
notifito uses asynchronous communication by default. Services communicate through an event bus (Redis queues) unless a human is waiting for the answer.
Communication Patterns
| Pattern | Use Case | Example |
|---|---|---|
| Asynchronous | Background processing | Matching, sending emails |
| Synchronous | Human waiting | Widget submit, dashboard API |
Event Bus Abstraction
The EventBus interface in packages/contracts provides a thin abstraction over Laravel queues. This exists so that moving to RabbitMQ later is a configuration change, not a refactor.
Interface
// packages/contracts/src/Bus/EventBus.php
interface EventBus
{
public function publish(DomainEvent $event): void;
/** @param iterable<DomainEvent> $events */
public function publishAll(iterable $events): void;
}
Implementations
| Implementation | Purpose |
|---|---|
QueueEventBus |
Production: publishes to Laravel queues |
FakeEventBus |
Testing: in-memory event collection |
Domain Event Interface
// packages/contracts/src/Bus/DomainEvent.php
interface DomainEvent
{
public function topic(): string;
public function correlationId(): string;
/** @return array<string, mixed> */
public function toArray(): array;
/** @param array<string, mixed> $payload */
public static function fromArray(array $payload): static;
}
Topic Routing
Events are routed to specific queues based on their topic.
Routing Table
| Topic | Producer | Consumer | Queue | Description |
|---|---|---|---|---|
availability.received |
edge, dashboard | core | core-matching |
New availability event |
subscription.confirmation_requested |
core | dispatch | dispatch-send |
Send confirmation email |
subscription.matched |
core | dispatch | dispatch-send |
Send notification email |
delivery.status_changed |
dispatch | core | core-delivery |
Delivery status update |
Queue Configuration
// apps/core/config/queue.php
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => 'core-matching,core-delivery',
'retry_after' => 300,
],
],
Event Payloads
AvailabilityEventReceived
Emitted when a seller signals availability.
// packages/contracts/src/Events/AvailabilityEventReceived.php
final readonly class AvailabilityEventReceived implements DomainEvent
{
public function __construct(
public string $eventId,
public string $tenantId,
public string $itemTypeId,
public ?string $itemId,
public array $attributes,
public ?int $capacity,
public ?string $availableFrom,
public ?string $availableUntil,
public AvailabilitySource $source,
public string $idempotencyKey,
public string $correlationId,
public string $occurredAt,
) {}
public function topic(): string
{
return 'availability.received';
}
}
Wire Format:
{
"event_id": "evt-123",
"tenant_id": "ten-456",
"item_type_id": "it-789",
"item_id": null,
"attributes": {"size": "42"},
"capacity": 3,
"available_from": null,
"available_until": null,
"source": "push_api",
"idempotency_key": "unique-key",
"correlation_id": "corr-abc",
"occurred_at": "2026-08-28T10:00:00+00:00"
}
ConfirmationRequested
Emitted when a subscription needs email confirmation.
// packages/contracts/src/Events/ConfirmationRequested.php
final readonly class ConfirmationRequested implements DomainEvent
{
public function __construct(
public string $tenantId,
public string $tenantName,
public string $subscriptionId,
public string $contactPointId,
public string $channel,
public string $contactValue,
public string $confirmUrl,
public string $itemTypeName,
public ?string $itemName,
public array $attributes,
public string $correlationId,
) {}
public function topic(): string
{
return 'subscription.confirmation_requested';
}
}
SubscriptionMatched
Emitted when availability matches subscriptions (batched).
// packages/contracts/src/Events/SubscriptionMatched.php
final readonly class SubscriptionMatched implements DomainEvent
{
public function __construct(
public string $tenantId,
public string $tenantName,
public string $availabilityEventId,
public string $itemTypeName,
public ?string $itemName,
public array $eventAttributes,
public array $matches, // List of MatchedSubscription
public string $correlationId,
) {}
public function topic(): string
{
return 'subscription.matched';
}
}
DeliveryStatusChanged
Emitted when delivery status updates.
// packages/contracts/src/Events/DeliveryStatusChanged.php
final readonly class DeliveryStatusChanged implements DomainEvent
{
public function __construct(
public string $tenantId,
public string $deliveryId,
public ?string $subscriptionId,
public string $contactPointId,
public string $status,
public ?string $detail,
public ?string $providerMessageId,
public string $occurredAt,
public string $correlationId,
) {}
public function topic(): string
{
return 'delivery.status_changed';
}
}
Transactional Outbox
The outbox pattern ensures events are never lost between commit and publish.
Problem
Without the outbox: 1. Domain change commits 2. Event is lost here (process crash, network failure) 3. Event is published
Solution
With the outbox: 1. Domain change and outbox row commit in same transaction 2. Relay publishes unpublished rows 3. Row is marked as published
Implementation
// apps/core/app/Domain/Outbox/OutboxEventBus.php
class OutboxEventBus implements EventBus
{
public function publish(DomainEvent $event): void
{
OutboxMessage::query()->create([
'topic' => $event->topic(),
'payload' => $event->toArray(),
'correlation_id' => $event->correlationId(),
]);
}
}
// apps/core/app/Domain/Outbox/OutboxRelay.php
class OutboxRelay
{
public function relay(): void
{
OutboxMessage::query()
->whereNull('published_at')
->each(function (OutboxMessage $message) {
try {
$this->bus->publish(
$this->reconstruct($message)
);
$message->update(['published_at' => now()]);
} catch (\Throwable $e) {
$message->update(['error' => $e->getMessage()]);
}
});
}
}
Relay Command
# Run the outbox relay
./bin/artisan core outbox:relay
# Run continuously
./bin/artisan core outbox:relay --watch
Correlation IDs
Every request carries a correlation ID from ingest through send, enabling end-to-end tracing.
Flow
Implementation
// edge generates or propagates correlation ID
$correlationId = $request->header('X-Request-ID')
?? Str::uuid()->toString();
// Carried through all events
$event = new AvailabilityEventReceived(
correlationId: $correlationId,
// ...
);
Queue Configuration
Horizon Supervisors
// apps/core/config/horizon.php
'environments' => [
'production' => [
'supervisor-matching' => [
'connection' => 'redis',
'queue' => ['core-matching'],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 6,
'tries' => 5,
'timeout' => 120,
],
'supervisor-delivery' => [
'connection' => 'redis',
'queue' => ['core-delivery'],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 3,
'tries' => 5,
'timeout' => 60,
],
],
],
Retry Configuration
| Queue | Tries | Backoff | Timeout |
|---|---|---|---|
core-matching |
5 | Default | 120s |
core-delivery |
5 | Default | 60s |
dispatch-send |
6 | 60, 300, 900, 3600, 10800s | 60s |
Synchronous Communication
Used only where a human is waiting for the answer.
edge → core
Widget subscription submit:
// edge validates and forwards to core
$response = Http::withHeaders([
'X-Notifito-Internal-Secret' => config('notifito.internal_secret'),
])
->post('http://core:8000/internal/subscriptions', $payload);
dashboard → core
All dashboard API calls:
// dashboard/app/Support/CoreClient.php
private function request(): PendingRequest
{
return Http::baseUrl(config('notifito.core_url'))
->withHeaders([
'X-Notifito-Internal-Secret' => config('notifito.internal_secret'),
]);
}
Internal Authentication
All internal HTTP calls require the X-Notifito-Internal-Secret header.
// Middleware in core
public function handle(Request $request, Closure $next)
{
$secret = $request->header('X-Notifito-Internal-Secret');
if (!hash_equals(config('notifito.internal_secret'), $secret)) {
return response()->json(['message' => 'Unauthorized'], 401);
}
return $next($request);
}
Error Handling
Lost Events
Prevented by the transactional outbox pattern. See Transactional Outbox.
Double Sends
Prevented by database constraints:
-- Availability event idempotency
ALTER TABLE availability_events
ADD CONSTRAINT availability_events_idempotency_key_unique
UNIQUE (idempotency_key);
-- Notification deduplication
ALTER TABLE notifications
ADD CONSTRAINT notifications_subscription_event_unique
UNIQUE (subscription_id, availability_event_id);
Provider Outages
Exponential backoff with dead-letter queue:
// apps/dispatch/app/Jobs/SendDeliveryJob.php
public int $tries = 6;
public function backoff(): array
{
return [60, 300, 900, 3600, 10800]; // ~6 attempts over several hours
}
Related Documentation
- Architecture Overview — System design
- Data Model — Database schema
- Configuration Reference — Environment variables