Beacon¶
Fictional reference product. Beacon is a customer-messaging SaaS: organizations subscribe to a plan, then send their customers transactional and campaign messages. Use these pages as a style reference — none of it is real.
Beacon sells one promise: let a company talk to its customers reliably, at scale, without running messaging infrastructure itself. A company signs up as an organization, picks a plan, and from then on sends two kinds of messages — the transactional ones a customer expects right now (a receipt, a password reset, a shipping update) and the campaign ones the company chooses to send (a product announcement, a seasonal offer). Beacon renders those messages from templates, hands them to the right provider for the channel, and records what happened to each one.
That's the whole product, and almost everything in these docs is a detail hanging off it. If you remember one sentence, make it this: organizations subscribe (Billing), made of users (Accounts), so they can send messages and see what was delivered (Messaging). The three words in parentheses are Beacon's three domains, and they are the spine of the entire knowledge base.
This page is L0 — the map. It tells you what Beacon is, how the pieces fit, and where to go next. It deliberately stays shallow: every claim here is explained in depth one or two clicks down. If you're new, read this top to bottom once, then jump to whichever domain or service you actually need.
Start here, by who you are¶
Different readers need different first clicks. Pick the row that fits.
| If you're… | Start with | Then |
|---|---|---|
| New to Beacon | This page, top to bottom | A domain that interests you |
| A product/PM reader | Features and the Glossary | The domains behind them |
| Integrating against the API | The public API and webhooks | auth |
| Working in a service | The service you own | Its models and datastores |
| Hunting why a thing is the way it is | The decisions (ADRs) | The model or service the ADR touches |
The three domains¶
Beacon is organized around three business domains. Each one owns a clear question, and the rest of the system reads from it rather than re-deriving its answers. Keeping these boundaries crisp is what stops Beacon turning into a tangle where everyone caches everyone else's data.
Accounts — who is the customer?¶
Accounts is the tenant and identity layer: organizations
and the users inside them. It is the layer every other domain reads from to learn
who exists, who belongs to whom, and whether an org is allowed to operate at all (an
org can be suspended). Accounts is the sole writer of organization and user
data — other domains read it over the API and must not treat a cached copy as
long-term truth.
Billing — what is the customer paying for?¶
Billing is the authority on money and entitlement: plans, subscriptions, invoices, and the proration math when a plan changes mid-cycle. When you need to know whether an org is entitled to a feature, how many seats it bought, or whether its last payment failed, Billing is the source of truth. It does not own users or seats (it reads those from Accounts) and it does not send messages — it only emits the events that cause messages to be sent.
Messaging — did the message get there?¶
Messaging owns the send flow end to end: message templates, send scheduling, the dispatch across channels (email, SMS, push), and the delivery-event log that records the outcome. Its scope is bounded precisely — it starts at "send" and ends at "delivered / failed." It reacts to events from Billing (for example, sending a confirmation when a subscription is upgraded) and owns no subscription or org state of its own.
A useful way to hold the three together: Accounts says who, Billing says what they're owed, Messaging does the actual talking. Each owns its truth; none reaches into another's database.
How Beacon fits together¶
The diagram below is the canonical picture of the runtime. The Web UI talks to the two HTTP services directly; Billing reads Accounts for org status and seat counts; and the only path into Messaging is asynchronous, over RabbitMQ. That last point is deliberate and worth internalizing — Messaging is event-driven, so a slow or down messaging-service never blocks a billing operation.
flowchart LR
UI[Web UI] --> billing[billing-service]
UI --> accounts[accounts-service]
billing --> accounts
billing -- subscription.upgraded --> mq[(RabbitMQ)]
mq --> messaging[messaging-service]
messaging --> mongo[(MongoDB<br/>delivery events)]
billing --> mysql[(MySQL)]
accounts --> mysql
messaging --> pg[(PostgreSQL)]
The same flow as a D2 "hero" diagram¶
A flow end to end: a plan upgrade¶
Diagrams show structure; a worked example shows behavior. Here's the canonical "happy path" that ties all three domains together — an org upgrades its plan and its admin gets a confirmation message. This is the Plan upgrade feature, traced through the system.
sequenceDiagram
participant UI as Web UI
participant B as billing-service
participant A as accounts-service
participant MQ as RabbitMQ
participant M as messaging-service
participant DB as MongoDB
UI->>B: POST /subscriptions/{id}/upgrade
B->>A: GET /orgs/{id} (seats + status)
B->>B: prorate, apply plan, sync seat_limit
B-->>MQ: emit subscription.upgraded
MQ-->>M: deliver subscription.upgraded
M->>M: render confirmation from template
M->>DB: write delivery_event
M-->>MQ: emit message.delivered
What to notice, because each step is a decision documented elsewhere:
- Billing reads Accounts, not its own copy. Before changing anything, Billing
asks Accounts for the org's current seats and status via
GET /orgs/{id}. Accounts is the sole writer; Billing reads live rather than trusting stale data. - The upgrade is synchronous; the message is not. The customer's HTTP request
returns as soon as the subscription is updated. Emitting
subscription.upgradedto RabbitMQ is fire-and-forget — the confirmation message rides an async path so a messaging hiccup can't fail the billing call. - Proration happens here, once. Charging the prorated difference for a mid-cycle change is Billing's job and lives in billing-service — no other service computes money. See Proration.
- Delivery is recorded, not assumed. Messaging writes a
delivery event for the send attempt and emits
message.delivered(ormessage.failed). "We tried" and "it arrived" are different facts, and Beacon keeps both.
Two related behaviors are worth knowing even though they're not on this diagram:
- Downgrades are not the mirror image of upgrades.
POST .../downgradeexists in the code but is suspected dead — see the callout below. Treat upgrade as the live path. - Failed payments don't cancel a subscription outright. A failed charge moves the
subscription to
past_dueand starts dunning — automated retry-and-notify — before anything is canceled.
The services¶
Three deployable services map onto the three domains. Each is documented as a black box: its responsibilities and its formal contract, not its internals. Note that the three services intentionally run different stacks — Beacon doesn't pretend to be a monorepo monolith, and the stack differences are real and load-bearing (Messaging is Elixir specifically because it fans out high-volume async work).
| Service | Domain | Stack | Interface | Owns |
|---|---|---|---|---|
| accounts-service | Accounts | Java/Spring · MySQL | HTTP | Organizations, users |
| billing-service | Billing | Ruby/Rails · MySQL | HTTP / OpenAPI (TypeSpec) | Plans, subscriptions, invoices, proration |
| messaging-service | Messaging | Elixir/Phoenix · PostgreSQL + MongoDB + RabbitMQ | Event-driven (AsyncAPI) | Templates, scheduling, delivery log |
How they actually talk to each other:
- accounts-service exposes
GET /orgs/{id}(seat count + status, consumed by billing-service) and a suspend endpoint. It is the sole writer of org data. - billing-service provides
GET /subscriptions/{id}andPOST /subscriptions/{id}/upgrade, emitssubscription.upgraded, and consumes accounts-service'sGET /orgs/{id}. It owns the Subscription and Plan models. - messaging-service consumes
subscription.upgraded(→ sends a confirmation) and producesmessage.deliveredandmessage.failed. It owns the Delivery event model.
accounts-service is a stub page
The accounts-service page is still a stub (low confidence) — its contract and
internals aren't fully traced yet. Its language frontmatter reads java and its
prose says Java/Spring, consistent with the architecture diagram and the
Accounts domain page.
Suspected dead
POST /subscriptions/{id}/downgrade exists in billing-service but no live path
appears to exercise it. Flagged while tracing the plan-change flow; needs
confirmation before anyone relies on or removes it. (See
Subscription for other suspected-dead remnants like
promo_ref.)
The core models¶
Four data models carry Beacon's state. Each lives in exactly one store, owned by exactly one service; the relationships between them frequently cross service and store boundaries, which is the single most important thing to understand before you write code that reads more than one of them.
| Model | Owner | Store | Why it's interesting |
|---|---|---|---|
| Subscription | billing-service | MySQL (billing.subscriptions, partly view-backed) |
Status + transitions enforced in app code, not the DB |
| Organization | accounts-service | MySQL (accounts.organizations) |
status is a real DB enum; seat_limit is synced in by billing |
| Delivery event | messaging-service | MongoDB (messaging.delivery_events) |
Schemaless, write-once, high-volume; fields appear over time |
| Plan | billing-service | MySQL | The package an org pays for; a subscription references exactly one |
The relationships are mostly logical, not foreign keys — and the docs are
emphatic about marking where each rule is enforced (DB · app · none) because
that's the highest-value thing a reader can learn:
erDiagram
ORGANIZATION ||--o{ USER : "has (DB FK)"
ORGANIZATION ||--|| SUBSCRIPTION : "has (cross-store)"
SUBSCRIPTION }o--|| PLAN : "on (app-only)"
SUBSCRIPTION ||--o{ DELIVERY_EVENT : "logged for (cross-store)"
The dangerous edges here are the cross-store ones. A Subscription's org_id points
at an Organization in a different service's database with no foreign key behind it,
so a deleted org can briefly leave a dangling subscription — readers must tolerate a
missing org. Likewise, a delivery event's org_id is purely logical. When in doubt,
read the model page: it spells out exactly which relationships are DB FK,
app-only, or cross-store.
Where data lives¶
Beacon spreads its state across four stores, each chosen for the shape of the data it
holds. The datastore pages document what each store holds;
services declare what they depend_on and the "used by" lists are generated from
that.
| Store | Holds | Used by |
|---|---|---|
| MySQL | Relational truth — orgs, users, plans, subscriptions, invoices | accounts-service, billing-service |
| PostgreSQL | Message templates and send schedules | messaging-service |
| MongoDB | The delivery-event log — high-volume, schemaless, write-once | messaging-service |
| RabbitMQ | The event bus between services (subscription.upgraded, message.*) |
billing-service, messaging-service |
The split inside Messaging is itself a decision: relational template/schedule data in PostgreSQL, but the firehose of delivery outcomes in MongoDB, where a schemaless, append-only collection suits write-once events that arrive at high volume and whose shape has changed over the years. That choice — and how consumers cope with fields that only exist on newer events — is written up in ADR-0001 and ADR-0002.
The documentation layers¶
These docs are organized in layers, from product-facing down to engineering detail — the same business → technical depth ladder each individual page follows. Navigate down for more depth, up for more context.
| Layer | Where | What you'll find |
|---|---|---|
| Domains | domains/ | The three business areas and their capabilities |
| Features | features/ | Product capabilities as end-to-end journeys, e.g. Plan upgrade |
| Flows | flows/ | Behaviour that varies — e.g. Send message |
| Surfaces | surfaces/ | The frontends: dashboard, admin console |
| Interfaces | interfaces/ | The programmatic surface: API, webhooks, auth |
| Integrations | integrations/ | Third parties Beacon calls: email, SMS |
| Services | services/ | Each service as a black box + its contract |
| Models | models/ | Data models and how they map to storage |
| Datastores | datastores/ | The databases and queues themselves |
| Decisions | decisions/ | ADRs — why things are the way they are |
| Glossary | glossary.md | Shared vocabulary — start here when a term is unfamiliar |
A few terms worth knowing up front¶
You'll meet these everywhere; the Glossary is the full list, but these four unlock most of the system:
- Seat — one paid user slot. "Seats in use" counts the ones actually assigned to users, which can be lower than the number paid for.
- Past due — a subscription whose latest payment failed. It enters dunning rather than cancelling immediately.
- Dunning — the automated retry-and-notify process that runs after a failed payment, before a subscription is canceled.
- Proration — the prorated charge applied when a plan changes mid-cycle, computed in billing-service.