Init Project
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s

This commit is contained in:
enkly
2026-10-30 18:06:55 +01:00
parent da8af794bd
commit 79ce8dc4c0
211 changed files with 44434 additions and 1 deletions

159
pkg/eventbus/bus.go Normal file
View 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)
}
}
}

View 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"
}

View 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"`
}

View 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
View 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,
},
}