Marketing Context¶
The Marketing context manages marketing campaigns, promotions, and advertising across multiple channels. It integrates with the Loyalty context for loyalty-bonus promotions.
Purpose¶
A 500-location restaurant chain runs promotional campaigns across email, social media, in-store signage, the mobile app, and the website. The Marketing context provides a structured workflow for creating, scheduling, launching, pausing, and ending campaigns — with built-in support for promotional offers.
Interview Connection¶
From the CEO's interview:
"I tried to get them to build a loyalty program a couple of years ago."
The Marketing context works alongside the
Loyalty context. Campaigns can
include LoyaltyBonus promotions that offer bonus points,
connecting marketing efforts directly to customer retention.
Types¶
The CampaignChannel enumeration captures the five channels
a campaign can target. The CampaignPromotion record type
bundles the promotion details, including which menu items it
applies to.
Entity: Campaign¶
The Campaign entity has a 5-command lifecycle:
event-sourced entity Campaign as flow is {
// An event-sourced entity OWNS the commands and events that change it, so
// they are declared INSIDE it, and every command names the event it yields.
command CreateCampaign yields event CampaignCreated is { campaignId: CampaignId }
command LaunchCampaign yields event CampaignLaunched is { campaignId: CampaignId }
event CampaignCreated is { campaignId: CampaignId }
event CampaignLaunched is { campaignId: CampaignId }
event LaunchCampaignRejected is { campaignId: CampaignId, rejectionReason: String(1,500) }
record CampaignData is { campaignId: CampaignId }
// Lifecycle phases are named STATES, not a status field: each state
// declares the commands it accepts, so the compiler knows the machine.
initial state ActiveCampaign of record CampaignData is {
handler ActiveCampaignHandler is {
on cmd: command LaunchCampaign is {
yield event CampaignLaunched(campaignId = cmd.campaignId)
}
// `set` and `morph` may appear ONLY in an `on event` clause here:
// replay has to re-apply exactly the same change.
on evt: event CampaignLaunched is {
morph entity Campaign to state Launched
with record CampaignData(campaignId = evt.campaignId)
}
}
}
state Launched of record CampaignData is {
handler LaunchedHandler is {
// A command this state does not accept is refused -- and the refusal
// is PUBLISHED before it is raised, so the attempt is recorded.
on cmd: command LaunchCampaign is {
send event LaunchCampaignRejected(campaignId = cmd.campaignId,
rejectionReason = "Campaign does not accept LaunchCampaign in this state")
to outlet CampaignEvents
error "Campaign does not accept LaunchCampaign in this state"
}
}
}
// A processor receives on its OWN inlet and publishes on its OWN outlet,
// and a portlet's type must ADMIT everything that travels on it.
type CampaignCommand is CreateCampaign | LaunchCampaign
type CampaignEvent is CampaignCreated | CampaignLaunched | LaunchCampaignRejected
inlet CampaignCommands is type CampaignCommand
outlet CampaignEvents is type CampaignEvent
}
The lifecycle: Create → Schedule → Launch → (optional Pause) → End.
Note that campaignChannels uses many CampaignChannel — a
single campaign can target multiple channels simultaneously.
The campaignPromotion is optional because not every
campaign includes a promotional offer; some are purely
awareness campaigns.
Repository¶
repository CampaignRepository as flow is {
inlet CampaignRepositoryFromCampaign is command PersistCampaignLaunched
outlet CampaignRepositoryResponses is result CampaignResult
// A repository answers with a RESULT, never an event.
result CampaignResult is { found: Boolean }
record StoredCampaign is { campaignId: CampaignId }
// A repository that answers queries and declares NO index at all is a
// sequential scan by construction, and draws a warning saying so.
schema CampaignSchema is relational
of rows as record StoredCampaign
index on field StoredCampaign.campaignId
command PersistCampaignLaunched is { campaignId: CampaignId }
handler CampaignPersistence is {
on command PersistCampaignLaunched is {
do "update the stored campaign row for this campaignId"
}
// An inlet admitting an alternation needs a clause that receives it.
// Handling each member is not enough -- say what ARRIVING means.
on other is {
do "persist whatever else arrives on this inlet"
}
}
}
Design Decisions¶
Why separate from Menu Management? Marketing campaigns and menu releases operate on different timelines, involve different stakeholders, and have different lifecycles. A marketing campaign might promote existing menu items without any menu changes. Keeping them separate means the marketing team doesn't need to coordinate with the menu workflow.
Why include LoyaltyBonus in PromotionType? This connects
marketing campaigns directly to the loyalty program. A campaign
can offer "Double Points Weekend" by specifying a LoyaltyBonus
promotion type. The Loyalty context processes the bonus points
independently.
Multi-channel support: The many CampaignChannel field
means a campaign targets specific channels. This enables
channel-specific analytics and allows campaigns to be launched
on some channels before others (e.g., email first, then
social media).