This commit is contained in:
159
pkg/eventbus/bus.go
Normal file
159
pkg/eventbus/bus.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
// Bus is a small, reconnect-free wrapper over one amqp091 connection and
|
||||
// channel. It declares ExchangeName as a durable topic exchange on
|
||||
// connect so publishers and consumers never race over who creates it
|
||||
// first.
|
||||
//
|
||||
// Deliberately minimal: no automatic reconnect/retry loop, no publisher
|
||||
// confirms, no dead-letter exchange. Those are real production
|
||||
// concerns, but adding them here would be infrastructure plumbing
|
||||
// unrelated to the DDD restructuring this package exists to
|
||||
// demonstrate — see the "Operational hardening" section of
|
||||
// ARCHITECTURE.md for what a production rollout should add before this
|
||||
// goes live.
|
||||
type Bus struct {
|
||||
conn *amqp.Connection
|
||||
channel *amqp.Channel
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// Connect dials url (an amqp:// URI) and declares the shared topology.
|
||||
func Connect(url string, logger *slog.Logger) (*Bus, error) {
|
||||
conn, err := amqp.Dial(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial rabbitmq: %w", err)
|
||||
}
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("open channel: %w", err)
|
||||
}
|
||||
if err := ch.ExchangeDeclare(ExchangeName, "topic", true, false, false, false, nil); err != nil {
|
||||
_ = ch.Close()
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("declare exchange: %w", err)
|
||||
}
|
||||
return &Bus{conn: conn, channel: ch, logger: logger}, nil
|
||||
}
|
||||
|
||||
// Close releases the underlying channel and connection.
|
||||
func (b *Bus) Close() error {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
if err := b.channel.Close(); err != nil {
|
||||
_ = b.conn.Close()
|
||||
return err
|
||||
}
|
||||
return b.conn.Close()
|
||||
}
|
||||
|
||||
// Envelope is the JSON structure every event is published as. OccurredAt
|
||||
// and RoutingKey are set by Publish; Payload is whatever event-specific
|
||||
// struct the caller passes (see pkg/eventbus/events).
|
||||
type Envelope struct {
|
||||
RoutingKey string `json:"routing_key"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
// Publish marshals payload and sends it to ExchangeName with the given
|
||||
// routing key, as a persistent message (survives a broker restart).
|
||||
// Fire-and-forget: the caller's own request/response flow (over gRPC)
|
||||
// must never block on this succeeding — a publish failure is logged,
|
||||
// not propagated, exactly like the monolith's own "log, don't fail the
|
||||
// request" stance on e.g. a cleanup-loop error.
|
||||
func (b *Bus) Publish(ctx context.Context, routingKey string, payload any) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal event payload: %w", err)
|
||||
}
|
||||
env := Envelope{RoutingKey: routingKey, OccurredAt: time.Now().UTC(), Payload: body}
|
||||
envBody, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal envelope: %w", err)
|
||||
}
|
||||
return b.channel.PublishWithContext(ctx, ExchangeName, routingKey, false, false, amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
DeliveryMode: amqp.Persistent,
|
||||
Timestamp: time.Now(),
|
||||
Body: envBody,
|
||||
})
|
||||
}
|
||||
|
||||
// PublishBestEffort calls Publish and logs (rather than returns) any
|
||||
// error — the shape every call site in this codebase actually wants,
|
||||
// since a notification being late or lost must never fail the
|
||||
// synchronous gRPC call that triggered it.
|
||||
func (b *Bus) PublishBestEffort(ctx context.Context, routingKey string, payload any) {
|
||||
if err := b.Publish(ctx, routingKey, payload); err != nil {
|
||||
b.logger.Error("publish event", "routing_key", routingKey, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Handler processes one decoded event. Returning an error nacks the
|
||||
// delivery with requeue=true; a consumer that can't make progress
|
||||
// should log and return nil instead of spinning forever on one poison
|
||||
// message (the notification service does exactly this — see
|
||||
// services/notification/internal/infrastructure/eventing).
|
||||
type Handler func(ctx context.Context, env Envelope) error
|
||||
|
||||
// Consume declares binding.Queue durable, binds it to every routing key
|
||||
// in binding.RoutingKeys, and runs handler for each delivery until ctx
|
||||
// is cancelled. Blocks the calling goroutine.
|
||||
func (b *Bus) Consume(ctx context.Context, binding QueueBinding, handler Handler) error {
|
||||
q, err := b.channel.QueueDeclare(binding.Queue, true, false, false, false, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("declare queue %s: %w", binding.Queue, err)
|
||||
}
|
||||
for _, key := range binding.RoutingKeys {
|
||||
if err := b.channel.QueueBind(q.Name, key, ExchangeName, false, nil); err != nil {
|
||||
return fmt.Errorf("bind queue %s to %s: %w", binding.Queue, key, err)
|
||||
}
|
||||
}
|
||||
// Process one message at a time per consumer goroutine — the
|
||||
// notification service's send-an-email handler is I/O bound on
|
||||
// SMTP, not CPU bound, so this keeps ordering simple without
|
||||
// needing a worker pool for the volumes this is sized for.
|
||||
if err := b.channel.Qos(1, 0, false); err != nil {
|
||||
return fmt.Errorf("set qos: %w", err)
|
||||
}
|
||||
deliveries, err := b.channel.Consume(q.Name, "", false, false, false, false, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("consume %s: %w", q.Name, err)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case d, ok := <-deliveries:
|
||||
if !ok {
|
||||
return fmt.Errorf("delivery channel closed for queue %s", q.Name)
|
||||
}
|
||||
var env Envelope
|
||||
if err := json.Unmarshal(d.Body, &env); err != nil {
|
||||
b.logger.Error("decode event envelope", "queue", q.Name, "err", err)
|
||||
_ = d.Nack(false, false) // malformed — don't requeue
|
||||
continue
|
||||
}
|
||||
if err := handler(ctx, env); err != nil {
|
||||
b.logger.Error("handle event", "queue", q.Name, "routing_key", env.RoutingKey, "err", err)
|
||||
_ = d.Nack(false, true)
|
||||
continue
|
||||
}
|
||||
_ = d.Ack(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
14
pkg/eventbus/events/billing_events.go
Normal file
14
pkg/eventbus/events/billing_events.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package events
|
||||
|
||||
// SubscriptionChanged is published by billing whenever applyStripeEvent
|
||||
// (now billing's internal/application use case of the same name)
|
||||
// commits a plan/status change from a Stripe webhook. No consumer in
|
||||
// this port of the monolith needs it synchronously — it's published so
|
||||
// a future consumer (e.g. a usage-analytics or dunning-email service)
|
||||
// can react without billing needing to know about it.
|
||||
type SubscriptionChanged struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Plan string `json:"plan"`
|
||||
Status string `json:"status"`
|
||||
EventType string `json:"stripe_event_type"` // e.g. "customer.subscription.updated"
|
||||
}
|
||||
12
pkg/eventbus/events/conversation_events.go
Normal file
12
pkg/eventbus/events/conversation_events.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package events
|
||||
|
||||
// MessageCreated is published by conversation on every db.AddMessage
|
||||
// equivalent. Unused by any consumer in this port — present so the
|
||||
// pattern (every aggregate commit gets an event, even before a consumer
|
||||
// exists for it) is consistent across contexts; see
|
||||
// pkg/eventbus.RoutingKeyMessageCreated.
|
||||
type MessageCreated struct {
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
52
pkg/eventbus/events/iam_events.go
Normal file
52
pkg/eventbus/events/iam_events.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Package events holds the JSON payload shape for every event listed in
|
||||
// pkg/eventbus's routing-key constants. Each type is versioned
|
||||
// implicitly by its routing key (a breaking change gets a new key, e.g.
|
||||
// "iam.user.registered.v2") rather than by a field in the struct, to
|
||||
// keep consumers' decoding code simple.
|
||||
package events
|
||||
|
||||
import "time"
|
||||
|
||||
// VerificationCodeIssued is published by iam whenever a 6-digit email
|
||||
// verification code is minted — at signup (Handlers.Signup) and on
|
||||
// resend (Handlers.ResendVerification). The notification service is its
|
||||
// only consumer today (pkg/eventbus.RoutingKeyVerificationCodeIssued).
|
||||
//
|
||||
// Carrying the raw code on the bus (rather than just a code ID) is a
|
||||
// deliberate, documented trade-off: it lets the email's content stay
|
||||
// exactly what the monolith sent without notification needing its own
|
||||
// read access to iam's verification_codes table (which would violate
|
||||
// "each service owns its own tables"). This is safe only because
|
||||
// RabbitMQ sits on the private service network, never reachable from
|
||||
// outside the cluster — see ARCHITECTURE.md's "Trust boundary of the
|
||||
// event bus" note.
|
||||
type VerificationCodeIssued struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// PasswordResetRequested is published by iam from Handlers.ForgotPassword.
|
||||
type PasswordResetRequested struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
ResetLink string `json:"reset_link"` // already absolute (BaseURL + path + token), matching h.absURL
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// UserRegistered is published once a new account row is committed
|
||||
// (before email verification). Reserved for consumers that care about
|
||||
// the account existing at all rather than about delivering mail — e.g.
|
||||
// an analytics or growth service added later.
|
||||
type UserRegistered struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// UserEmailVerified is published when VerifyEmailCode succeeds.
|
||||
type UserEmailVerified struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
63
pkg/eventbus/topology.go
Normal file
63
pkg/eventbus/topology.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Package eventbus is enkly's thin wrapper around RabbitMQ (via
|
||||
// github.com/rabbitmq/amqp091-go) for every *asynchronous*,
|
||||
// fire-and-forget cross-context notification — the counterpart to the
|
||||
// synchronous, request/response calls services make over gRPC (see
|
||||
// api/proto). Per-service request/response traffic (e.g. the agent
|
||||
// service asking billing "is this user over quota?") always uses gRPC;
|
||||
// RabbitMQ is reserved for events a publisher doesn't need an answer to,
|
||||
// mirroring the rule stated in the task: "gRPC for synchronous
|
||||
// communication, RabbitMQ for asynchronous communication."
|
||||
//
|
||||
// Topology: one durable topic exchange, "enkly.events". Each event is
|
||||
// published with a routing key of the form "<context>.<aggregate>.
|
||||
// <event>" (e.g. "iam.user.registered"); a consumer binds a durable
|
||||
// queue to the routing keys it cares about. Using a topic exchange
|
||||
// rather than one queue per consumer lets a new consumer be added later
|
||||
// (e.g. an analytics service) without touching the publisher.
|
||||
package eventbus
|
||||
|
||||
// ExchangeName is the single durable topic exchange every service
|
||||
// publishes domain events to.
|
||||
const ExchangeName = "enkly.events"
|
||||
|
||||
// Routing keys. Each constant is "<owning context>.<aggregate>.<event,
|
||||
// past tense>" — only the owning bounded context ever publishes to its
|
||||
// own keys (see each service's internal/infrastructure/eventing
|
||||
// package), but any service may subscribe.
|
||||
const (
|
||||
// Published by iam (services/iam) — see internal/handlers/auth.go's
|
||||
// sendVerificationEmail/sendResetEmail, now decoupled from the
|
||||
// synchronous request path.
|
||||
RoutingKeyVerificationCodeIssued = "iam.user.verification_code_issued"
|
||||
RoutingKeyPasswordResetRequested = "iam.user.password_reset_requested"
|
||||
RoutingKeyUserRegistered = "iam.user.registered"
|
||||
RoutingKeyUserEmailVerified = "iam.user.email_verified"
|
||||
|
||||
// Published by billing (services/billing).
|
||||
RoutingKeySubscriptionChanged = "billing.subscription.changed"
|
||||
|
||||
// Published by conversation (services/conversation) — not consumed
|
||||
// by anything in this port of the monolith, but kept so a future
|
||||
// consumer (search indexing, analytics, audit) has something to
|
||||
// bind to without a producer-side change.
|
||||
RoutingKeyMessageCreated = "conversation.message.created"
|
||||
)
|
||||
|
||||
// QueueBinding names the durable queue a consumer should declare and
|
||||
// the routing key(s) it should bind, so every consumer's topology is
|
||||
// declared in one place next to the keys it reads.
|
||||
type QueueBinding struct {
|
||||
Queue string
|
||||
RoutingKeys []string
|
||||
}
|
||||
|
||||
// NotificationQueueBindings is the notification service's subscription
|
||||
// (services/notification/internal/infrastructure/eventing) — every
|
||||
// event that results in an outbound email.
|
||||
var NotificationQueueBindings = QueueBinding{
|
||||
Queue: "enkly.notification.verification_and_reset",
|
||||
RoutingKeys: []string{
|
||||
RoutingKeyVerificationCodeIssued,
|
||||
RoutingKeyPasswordResetRequested,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user