Dentro Parte 1 abbiamo coperto la filosofia e la panoramica delle serie. Parte 1.1 Abbiamo costruito il generatore di dati campione.
Ora costruiamo il sistema centrale. Questa parte si concentra su:
Questo è il progetto campione (Mostlylucid.SegmentCommerce) Una piccola demo di ecommerce che mostra i core pattern. È intenzionalmente semplificata per dimostrare chiaramente i concetti, ma include anche i reali pattern di infrastruttura (gestione dei messaggi basata sull'outbox, elaborazione di background job, indicizzazione JSONB) che si scalerebbe orizzontalmente nella produzione.
Pensate a questo come "modelli di produzione in un singolo-app fattore di forma." La stessa struttura di codice funziona sia che si sta eseguendo un processo o distribuendo attraverso i servizi.
Abbiamo scelto una pila semplice e coesa mantenere il campione chiaro durante la dimostrazione dei modelli di produzione.
Invece di database vettoriali separati, code di messaggi e livelli di cache, usiamo PostgreSQL:
flowchart LR
App[ASP.NET Core] --> PG[(PostgreSQL)]
PG --> JSONB[JSONB<br/>interests]
PG --> Vector[pgvector<br/>embeddings]
PG --> Queue[Queue tables<br/>jobs, outbox]
PG --> FTS[Full-text<br/>tsvector]
style PG stroke:#2f9e44,stroke-width:3px
style JSONB stroke:#1971c2,stroke-width:2px
style Vector stroke:#1971c2,stroke-width:2px
style Queue stroke:#1971c2,stroke-width:2px
style FTS stroke:#1971c2,stroke-width:2px
Un pool di connessioni, un backup, uno schieramento.
<!-- Instant search (no page reload) -->
<input
hx-get="/api/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#results" />
UX SPA-like con la semplicità del server-rendering.
App singola, ma scala dei modelli:
Inizia semplice. Distribuisci quando necessario.
Tradizionale negozio di tracciamento degli utenti informazioni identificabili: nomi, e-mail, ID utente legati al comportamento. GDPR e norme sulla privacy rendono questo sempre più problematico. Il nostro approccio è diverso:
Immagazziniamo schemi comportamentali, non identita'.
flowchart LR
subgraph "Traditional Approach"
User1[User: [email protected]] --> Behavior1[Bought headphones]
Behavior1 --> PII1[(PII Database)]
end
subgraph "Zero-PII Approach"
User2[Anonymous Session] --> Behavior2[Bought headphones]
Behavior2 --> Pattern[(Category: tech<br/>Signal weight: 1.0)]
end
style PII1 stroke:#c92a2a,stroke-width:3px
style Pattern stroke:#2f9e44,stroke-width:3px
L'intuizione chiave: Non c'e' bisogno di sapere chi e' qualcuno per sapere cosa gli interessa..
Il sistema ha tre livelli principali:
flowchart TB
Browser[Browser] --> Session[Session Collector]
Session --> Profile[Persistent Profile]
Profile --> Segments[Segment Service]
Session -->|in-memory only| Cache[(IMemoryCache)]
Profile -->|elevated signals| DB[(PostgreSQL + JSONB)]
Segments -->|memberships| UI[Segment Explorer UI]
style Session stroke:#1971c2,stroke-width:3px
style Profile stroke:#2f9e44,stroke-width:3px
style Segments stroke:#fab005,stroke-width:3px
style Cache stroke:#e64980,stroke-width:2px
Distinzione critica: Le sessioni sono effimere. I profili sono persistenti. Questo non è un dettaglio di implementazione è una decisione di architettura della privacy.
Le sessioni sono rigorosamente in memoria. Vivono in IMemoryCache con scadenza scorrevole e non toccare mai il database.
Questo è un vincolo architettonico difficile: i dati di sessione non possono persistere. Raccoglie segnali durante una visita e sfratti dopo 30 minuti di inattività tramite la politica di cache LFU (Least Frequently Used) sotto pressione di memoria.
// Mostlylucid.SegmentCommerce/Models/SessionProfile.cs
public class SessionProfile
{
public string SessionKey { get; set; } = string.Empty;
// Category interest scores: { "tech": 0.75, "fashion": 0.25 }
public Dictionary<string, double> Interests { get; set; } = new();
// Detailed signal counts: { "tech": { "product_view": 5, "add_to_cart": 1 } }
public Dictionary<string, Dictionary<string, int>> Signals { get; set; } = new();
// Products viewed this session
public List<int> ViewedProducts { get; set; } = new();
// Session context (device, referrer domain, time-of-day)
public SessionContext? Context { get; set; }
// Aggregates
public double TotalWeight { get; set; }
public int SignalCount { get; set; }
public int PageViews { get; set; }
public int ProductViews { get; set; }
public int CartAdds { get; set; }
// Timestamps
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
public DateTime LastActivityAt { get; set; } = DateTime.UtcNow;
// Link to persistent profile (if fingerprint resolved)
public Guid? PersistentProfileId { get; set; }
}
// Mostlylucid.SegmentCommerce/Services/Profiles/SessionCollector.cs
_cache.Set(sessionKey, sessionProfile, new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(30),
Priority = CacheItemPriority.Normal, // LFU eviction under memory pressure
// CRITICAL: Eviction callback decides whether to elevate to persistent profile
PostEvictionCallbacks =
{
new PostEvictionCallbackRegistration
{
EvictionCallback = async (key, value, reason, state) =>
{
if (value is SessionProfile session && ShouldElevate(session))
{
// Only NOW do we write to database (persistent profile)
await ElevateToProfileAsync(session);
}
// Otherwise: session is gone forever
}
}
}
});
Costrizioni dure:
private bool ShouldElevate(SessionProfile session)
{
// Elevate if:
// - User added to cart (high intent)
// - User completed purchase (conversion)
// - Fingerprint was resolved (identity established)
// - Session weight exceeds threshold (engaged visitor)
return session.CartAdds > 0
|| session.TotalWeight > 5.0
|| session.PersistentProfileId.HasValue;
}
Perche' l'evitazione e' stata richiamata?
Questo è il solo punto sicuro per decidere la persistenza. Entro il momento in cui la cache sfratta la sessione:
Se non eleviamo durante lo sgombero, la sessione è se n'è andato per sempre. Questo è il design.
public class SessionContext
{
public string? DeviceType { get; set; } // "mobile", "desktop"
public string? EntryPath { get; set; } // "/products/tech" (no query params)
public string? ReferrerDomain { get; set; } // "google.com" (domain only, not full URL)
public string? TimeOfDay { get; set; } // "morning", "afternoon"
public string? DayType { get; set; } // "weekday", "weekend"
}
Guarda cosa c'e'. non qui: indirizzi IP, interpreti, URL completi, pixel di monitoraggio. Catturamo modelli di contesto, informazioni non identificabili.
A segnale è un fatto comportamentale catturato durante un'azione utente. Invece di rintracciare "chi," tracciamo "quello che è successo."
Questo concetto deriva da Segnali effimeriIn questo sistema, le operazioni emettono segnali come: "api.rate_limited" oppure "gateway.slow" per coordinare il comportamento senza stretto accoppiamento.
Qui applichiamo lo stesso modello al comportamento dell'utente:
product_view segnale (peso: 0,10)add_to_cart segnale (peso: 0,35)purchase segnale (peso: 1,00)I segnali sono effimero (espire con la sessione), zero-PII (nessuna identità), e ponderato (Intent-aware).
Per collegare le sessioni senza tracciare i cookie, utilizziamo impronta digitale lato client. Il browser calcola un hash da segnali (timezone, risoluzione dello schermo, Renderer WebGL, impronte digitali su tela) e invia solo l'hashish a /api/fingerprint.
Il server quindi HMAC che hash con una chiave segreta, che lo rende site-scoped e inutilizzabile altrove.
Questo codice è adattato dal mio Progetto di rilevamento dei bot, dove viene usato per identificare i raschiatori. Stessa tecnica, scopo diverso.
// Mostlylucid.SegmentCommerce/ClientFingerprint/fingerprint.js
// Collect signals (browser capabilities, not PII)
var signals = [
Intl.DateTimeFormat().resolvedOptions().timeZone,
navigator.language,
screen.width + 'x' + screen.height,
// ... (see full code)
];
// Hash locally
var hash = hash(signals.join('|'));
// Send only the hash via sendBeacon
navigator.sendBeacon('/api/fingerprint', JSON.stringify({ h: hash }));
lato server:
// Server HMACs the client hash with a secret key
var profileKey = HMACSHA256(clientHash + secretKey);
Ora abbiamo un identificatore stabile, mappato dal sito, senza cookies o storage locale. fonte completa di impronte digitali.js (adattato da per lo più Lucid.botdetection).
Le diverse azioni hanno diversi livelli di intenti. pesi di base.
// Mostlylucid.SegmentCommerce/Data/Entities/Profiles/SignalEntity.cs
public static class SignalTypes
{
// Passive signals (low intent)
public const string PageView = "page_view"; // 0.01
public const string CategoryBrowse = "category_browse"; // 0.03
public const string ProductImpression = "product_impression"; // 0.02
// Active signals (medium intent)
public const string ProductView = "product_view"; // 0.10
public const string ProductClick = "product_click"; // 0.08
public const string Search = "search"; // 0.05
// High-intent signals
public const string AddToCart = "add_to_cart"; // 0.35
public const string AddToWishlist = "add_to_wishlist"; // 0.25
public const string ViewCart = "view_cart"; // 0.15
public const string BeginCheckout = "begin_checkout"; // 0.40
// Conversion signals (highest intent)
public const string Purchase = "purchase"; // 1.00
public const string Review = "review"; // 0.60
public const string Share = "share"; // 0.50
public static readonly Dictionary<string, double> BaseWeights = new()
{
{ PageView, 0.01 },
{ ProductView, 0.10 },
{ AddToCart, 0.35 },
{ Purchase, 1.00 },
// ... (see full code for complete list)
};
public static double GetBaseWeight(string signalType)
{
return BaseWeights.GetValueOrDefault(signalType, 0.05);
}
}
Perche' questo e' importante:
0.01) non dominerà il segnale0.35) è un forte segnale d'intenti1.00) è il segnale più forteQuesta gerarchia impedisce "drive-by browsing" di inquinare il profilo.
// Mostlylucid.SegmentCommerce/Services/Profiles/SessionCollector.cs
public async Task<SessionProfile> RecordSignalAsync(
SessionSignalInput input, CancellationToken ct = default)
{
var sessionKey = input.SessionKey;
// Get or create session FROM CACHE (never DB)
var session = _cache.Get<SessionProfile>(sessionKey);
if (session == null)
{
session = new SessionProfile
{
SessionKey = sessionKey,
StartedAt = DateTime.UtcNow
};
}
session.LastActivityAt = DateTime.UtcNow;
var weight = input.Weight ?? SignalTypes.GetBaseWeight(input.SignalType);
// Update in-memory aggregates
session.TotalWeight += weight;
session.SignalCount++;
if (!string.IsNullOrEmpty(input.Category))
{
session.Interests.TryGetValue(input.Category, out var currentScore);
session.Interests[input.Category] = currentScore + weight;
}
if (input.SignalType == SignalTypes.AddToCart)
{
session.CartAdds++;
}
// Put back in cache with sliding expiration
_cache.Set(sessionKey, session, new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(30),
Priority = CacheItemPriority.Normal,
PostEvictionCallbacks = { /* elevation callback */ }
});
return session;
}
Veloce perché:
Quando una sessione mostra alto intento (cart aggiunge, acquista), eleviamo segnali ad un profilo persistente.
// Mostlylucid.SegmentCommerce/Data/Entities/Profiles/PersistentProfileEntity.cs
[Table("persistent_profiles")]
public class PersistentProfileEntity
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(256)]
public string ProfileKey { get; set; } = string.Empty;
// How this profile is identified (Fingerprint, Cookie, Identity)
public ProfileIdentificationMode IdentificationMode { get; set; }
// Behavioral data (all JSONB)
[Column("interests", TypeName = "jsonb")]
public Dictionary<string, double> Interests { get; set; } = new();
[Column("affinities", TypeName = "jsonb")]
public Dictionary<string, double> Affinities { get; set; } = new();
[Column("brand_affinities", TypeName = "jsonb")]
public Dictionary<string, double> BrandAffinities { get; set; } = new();
[Column("price_preferences", TypeName = "jsonb")]
public PricePreferences? PricePreferences { get; set; }
[Column("traits", TypeName = "jsonb")]
public Dictionary<string, bool> Traits { get; set; } = new();
// Computed segments
public ProfileSegments Segments { get; set; } = ProfileSegments.None;
[Column("llm_segments", TypeName = "jsonb")]
public Dictionary<string, double>? LlmSegments { get; set; }
// Vector embedding for similarity matching
[Column("embedding", TypeName = "vector(384)")]
public Vector? Embedding { get; set; }
// Statistics
public int TotalSessions { get; set; }
public int TotalSignals { get; set; }
public int TotalPurchases { get; set; }
public int TotalCartAdds { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
Ancora zero PII:
ProfileKey è un hash HMAC (non reversibile)IdentificationMode ci dice come è stato identificato (fingerprint/cookie/login)public async Task ElevateToProfileAsync(
SessionProfileEntity session,
PersistentProfileEntity profile,
CancellationToken ct = default)
{
if (session.IsElevated)
return;
// Merge interests (use higher value)
foreach (var (category, score) in session.Interests)
{
if (!profile.Interests.ContainsKey(category) ||
profile.Interests[category] < score)
{
profile.Interests[category] = score;
}
}
// Update stats
profile.TotalSessions++;
profile.TotalSignals += session.SignalCount;
profile.TotalCartAdds += session.CartAdds;
profile.LastSeenAt = DateTime.UtcNow;
profile.UpdatedAt = DateTime.UtcNow;
// Mark session as elevated
session.IsElevated = true;
session.PersistentProfileId = profile.Id;
// Clear segment cache (will be recomputed)
profile.SegmentsComputedAt = null;
profile.EmbeddingComputedAt = null;
await _db.SaveChangesAsync(ct);
}
Quando si verifica l'elevazione:
Abbiamo preso in giro segmenti per due parti. Ora consegniamo. output attuabile di tutti quei segnali di raccolta rispondono "che tipo di shopper è questo?"
La segmentazione tradizionale è binaria: o sei nel segmento o non lo sei. Questo crea problemi:
flowchart LR
subgraph "Binary Segmentation"
Profile1[3 purchases] -->|"Threshold: 5"| Out[NOT High-Value]
Profile2[5 purchases] -->|"Threshold: 5"| In[High-Value]
end
Un cliente con 4 acquisti è trattato in modo identico a uno con 0. Questo è sbagliato.
Segmentazione fuzzy dà ad ogni profilo un punteggio (0-1):
| Acquisti | Binario | Punteggio sfocato |
|---|---|---|
| 0 | No | 0 |
| 2 | No | 0.4 |
| 4 | No | 0.8 |
| 5+ | Sì | 1,0 |
Ora puoi personalizzare proporzionalmente: i clienti "quasi di alto valore" ricevono un trattamento leggermente diverso da "non vicino."
// Mostlylucid.SegmentCommerce/Services/Segments/SegmentDefinition.cs
public class SegmentDefinition
{
public string Id { get; set; } // "tech-enthusiast"
public string Name { get; set; } // "Tech Enthusiasts"
public string Description { get; set; } // "Users with strong interest in technology"
public string Icon { get; set; } // "🔧"
public string Color { get; set; } // "#3b82f6"
public List<SegmentRule> Rules { get; set; } = [];
// How rules combine: All (AND), Any (OR), Weighted (sum)
public RuleCombination Combination { get; set; } = RuleCombination.Weighted;
// Minimum score to be "a member" (0-1)
public double MembershipThreshold { get; set; } = 0.3;
public List<string> Tags { get; set; } = []; // For filtering/grouping
}
Ogni regola valuta una dimensione del profilo:
public enum RuleType
{
CategoryInterest, // Check interests.tech, interests.fashion, etc.
BrandAffinity, // Check brandAffinities.Sony, brandAffinities.Nike, etc.
PriceRange, // Check price preferences (budget vs luxury)
Trait, // Check boolean traits (prefersDeals, browsesExtensively)
Statistic, // Check totalPurchases, totalSessions, totalCartAdds
TagAffinity, // Check affinities.gadgets, affinities.organic, etc.
Recency, // Check days since last activity
Expression // Custom expressions (advanced)
}
public enum RuleOperator
{
GreaterThan, // value > threshold
GreaterOrEqual, // value >= threshold
LessThan, // value < threshold
LessOrEqual, // value <= threshold
Equal, // value == threshold
NotEqual, // value != threshold
Contains, // for array/string checks
Between, // for ranges
In, NotIn // for set membership
}
Come le regole multiple si combinano in un punteggio finale:
public enum RuleCombination
{
All, // AND logic: Score = min(all rule scores). All rules must pass.
Any, // OR logic: Score = max(all rule scores). Any rule can pass.
Weighted // Weighted sum: Score = Σ(rule.weight × rule.score) / Σ(rule.weight)
}
ponderato è la maggior parte delle cose comuni, ti permette di dire "la categoria di interesse conta il 60%, la reputazione conta il 30%, l'affinità del marchio conta il 10%."
Ecco i segmenti predefiniti del progetto campione:
new SegmentDefinition
{
Id = "tech-enthusiast",
Name = "Tech Enthusiasts",
Description = "Users with strong interest in technology products",
Icon = "🔧",
Color = "#3b82f6",
MembershipThreshold = 0.35,
Rules =
[
new() {
Type = RuleType.CategoryInterest,
Field = "interests.tech",
Operator = RuleOperator.GreaterOrEqual,
Value = 0.4,
Weight = 0.6, // 60% of score
Description = "Tech interest > 40%"
},
new() {
Type = RuleType.TagAffinity,
Field = "affinities.gadgets",
Operator = RuleOperator.GreaterOrEqual,
Value = 0.2,
Weight = 0.2, // 20% of score
Description = "Likes gadgets"
},
new() {
Type = RuleType.TagAffinity,
Field = "affinities.electronics",
Operator = RuleOperator.GreaterOrEqual,
Value = 0.2,
Weight = 0.2, // 20% of score
Description = "Likes electronics"
}
]
}
Esempio di valutazione:
interests.tech = 0.72, affinities.gadgets = 0.31, affinities.electronics = 0.15new SegmentDefinition
{
Id = "cart-abandoner",
Name = "Cart Abandoners",
Description = "Users who add items to cart but don't complete purchase",
Icon = "🛒",
Color = "#ef4444",
MembershipThreshold = 0.4,
Rules =
[
new() {
Type = RuleType.Statistic,
Field = "totalCartAdds",
Operator = RuleOperator.GreaterOrEqual,
Value = 3,
Weight = 0.5,
Description = "3+ cart adds"
},
new() {
Type = RuleType.Statistic,
Field = "totalPurchases",
Operator = RuleOperator.LessThan,
Value = 2,
Weight = 0.5,
Description = "Few purchases"
}
]
}
Questo cattura il "aggiunge al carrello ma non compra" modello perfetto per le campagne di recupero.
new SegmentDefinition
{
Id = "high-value",
Name = "High-Value Customers",
Description = "Customers who make frequent purchases and spend above average",
Icon = "💎",
Color = "#8b5cf6",
MembershipThreshold = 0.4,
Rules =
[
new() {
Type = RuleType.Statistic,
Field = "totalPurchases",
Operator = RuleOperator.GreaterOrEqual,
Value = 3,
Weight = 0.4,
Description = "3+ purchases"
},
new() {
Type = RuleType.PriceRange,
Field = "priceRange",
Value = "100-10000", // High-end shoppers
Weight = 0.3,
Description = "High price range"
},
new() {
Type = RuleType.Recency,
Field = "lastSeen",
Operator = RuleOperator.LessThan,
Value = 30,
Weight = 0.3,
Description = "Active in last 30 days"
}
]
}
new SegmentDefinition
{
Id = "bargain-hunter",
Name = "Bargain Hunters",
Description = "Price-sensitive shoppers who love deals and discounts",
Icon = "🏷️",
Color = "#22c55e",
MembershipThreshold = 0.3,
Rules =
[
new() {
Type = RuleType.PriceRange,
Field = "priceRange",
Value = "0-75", // Budget shoppers
Weight = 0.5,
Description = "Low price preference"
},
new() {
Type = RuleType.Trait,
Field = "traits.prefersDeals",
Value = true,
Weight = 0.3,
Description = "Prefers deals"
},
new() {
Type = RuleType.Statistic,
Field = "totalCartAdds",
Operator = RuleOperator.GreaterThan,
Value = 5,
Weight = 0.2,
Description = "Shops around"
}
]
}
Il campione comprende 10 segmenti che coprono modelli comuni di commercio elettronico:
| Segmento | Icona | Regole chiave | Caso d'uso | ||
|---|---|---|---|---|---|
| Clienti di alto valore | Acquisti 3+, spesa elevata, recente | Trattamento VIP, programmi di fedeltà | |||
| Entusiasti della tecnologia | Interesse della tecnologia, affinità del gadget | Raccomandazioni del prodotto tecnico | |||
| Moda avanti | Moda interesse, visite multiple | Consigli di stile | |||
| Cacciatori di derivazione | Fasce di prezzo basse, prediligono offerte | Notifiche di vendita | |||
| Nuovi Visitatori | Nuovi Visitatori | Sezioni ≤2, nessun acquisto | Offerte di primo acquisto | ||
| Carrello Abbandonatori | 3+ carrello aggiunge, pochi acquisti | E-mail di recupero | |||
| Entusiasti della casa | Interesse della casa, attività recente | Vendita a domicilio | Vendita a domicilio | Vendita a domicilio | Vendita a domicilio |
| Fitness Active | Sport interest, health traits | Fitness product focus | |||
| Clienti leali | Acquisti 5+, 10+ sessioni | Conservazione, ricompense | |||
| Cercatori | Alti segnali, sfoglia ampiamente | Strumenti di confronto, informazioni dettagliate |
La SegmentService valuta i profili rispetto a tutte le regole del segmento:
// Mostlylucid.SegmentCommerce/Services/Segments/SegmentService.cs
public SegmentMembership EvaluateSegment(ProfileData profile, SegmentDefinition segment)
{
var ruleScores = new List<RuleScore>();
foreach (var rule in segment.Rules)
{
var (score, actualValue) = EvaluateRule(profile, rule);
ruleScores.Add(new RuleScore
{
RuleDescription = rule.Description,
Score = score,
Weight = rule.Weight,
ActualValue = actualValue // For transparency
});
}
// Combine based on segment's combination method
double finalScore = segment.Combination switch
{
RuleCombination.All => ruleScores.Min(r => r.Score),
RuleCombination.Any => ruleScores.Max(r => r.Score),
RuleCombination.Weighted => ComputeWeightedScore(ruleScores),
_ => 0
};
return new SegmentMembership
{
SegmentId = segment.Id,
SegmentName = segment.Name,
Score = Math.Round(finalScore, 3),
IsMember = finalScore >= segment.MembershipThreshold,
RuleScores = ruleScores,
Confidence = score switch // Human-readable
{
>= 0.8 => "Very High",
>= 0.6 => "High",
>= 0.4 => "Medium",
>= 0.2 => "Low",
_ => "Very Low"
}
};
}
Ogni risultato dell'adesione comprende: valori effettivi che ha portato al punteggio:
// What the UI receives:
{
"segmentId": "tech-enthusiast",
"segmentName": "Tech Enthusiasts",
"score": 0.95,
"isMember": true,
"confidence": "Very High",
"ruleScores": [
{ "description": "Tech interest > 40%", "score": 1.0, "actualValue": "0.72" },
{ "description": "Likes gadgets", "score": 1.0, "actualValue": "0.31" },
{ "description": "Likes electronics", "score": 0.75, "actualValue": "0.15" }
]
}
Gli utenti possono vedere esattamente perché Questo è fondamentale per la trasparenza e il rispetto del GDPR.
Ecco come una vista di prodotto diventa un segmento di appartenenza:
sequenceDiagram
participant Browser
participant Cache as SessionCache
participant Outbox as Outbox
participant Segment as SegmentService
Browser->>Cache: Product view (category: "tech")
Cache->>Cache: Update in-memory session
Note over Cache: interests.tech += 0.10
Browser->>Cache: Add to cart (high intent)
Cache->>Outbox: Publish elevation event
Outbox->>Outbox: Write to outbox table
Note over Outbox: Background worker processes
Outbox->>Segment: Elevate to PersistentProfile
Segment->>Segment: Evaluate segment rules
Note over Segment: interests.tech: 0.72 >= 0.40 ✓<br/>Score: 0.95, IsMember: true
Segment-->>Browser: Segment memberships + explanations
Tutte le azioni significative scorrono attraverso il Schema outboxIl meccanismo di orchestrazione primaria:
flowchart LR
Action[Cart Add] --> TX[Single Transaction]
TX --> DB[(Save + Outbox)]
DB --> Worker[Background Worker]
Worker --> Route[Route to Handlers]
style TX stroke:#2f9e44,stroke-width:3px
Perché? I dati aziendali e gli eventi sono scritti in una singola transazione. Gli eventi non possono essere persi. I fallimenti riprovano automaticamente con il backoff esponenziale.
// Every action publishes to outbox in the same transaction
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
cart.Items.Add(new CartItem { ProductId = productId });
await _db.SaveChangesAsync(ct);
_outbox.Publish(OutboxEventTypes.ProductAddedToCart, new { ProductId = productId });
await _db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
// Event is now guaranteed to be processed
Parte 3 copre l'implementazione full outbox: la coda di lavoro, LISTEN/NOTIFY per il pickup istantaneo, riprova la logica e i modelli di scala.
Questa parte riguardava:
Parte 3 va più a fondo in:
SKIP LOCKED per il trattamento distribuitoI segmenti sono l'uscita attivabile di questo sistema. Rispondono "che tipo di shopper è questo?" con punteggi fuzzy, non secchi binari. E ogni utente può vedere esattamente perché sono in un segmento.
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.