Context & Interface Patterns Chapter 02

How 1Engage uses context.Context for value propagation, timeouts, cancellation, and distributed tracing — and how Go interfaces define clean contracts between layers with manual dependency injection.

2.1   context.Context — Value Propagation

context.WithValue stores request-scoped data (tenant identity, JWT claims, request IDs) so every layer in the call chain can access it without threading extra parameters through every function signature.

In a multi-tenant system each request carries tenant identity, user claims, and a correlation ID. Context is the idiomatic Go mechanism to propagate this metadata from middleware → handler → service → repository without polluting function signatures.

TenantContext — stored & retrieved from context

type TenantContextKey string

const key TenantContextKey = "tenantContext"

type TenantContext struct {
    UserID    string
    TenantID  string
    Role      string
    Subdomain string
}

func InjectTenantContext(ctx context.Context, tc TenantContext) context.Context {
    return context.WithValue(ctx, key, tc)
}

func GetTenantContext(ctx context.Context) (TenantContext, bool) {
    tc, ok := ctx.Value(key).(TenantContext)
    return tc, ok
}

JWT Claims — stored in context

type contextKey string

const ClaimsKey contextKey = "claims"

func WithClaims(ctx context.Context, claims map[string]interface{}) context.Context {
    return context.WithValue(ctx, ClaimsKey, claims)
}

func ClaimsFromContext(ctx context.Context) map[string]interface{} {
    v := ctx.Value(ClaimsKey)
    if v == nil {
        return nil
    }
    return v.(map[string]interface{})
}

Subdomain — stored in context by middleware

type ContextKey string

const SubdomainContextKey ContextKey = "subdomain"

func TenantIdentifierMiddleware(baseDomain string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // ... extract subdomain from Host header ...
            ctx := context.WithValue(r.Context(), SubdomainContextKey, subdomain)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

Request ID — stored in context

const RequestIDContextKey ContextKey = "request_id"

func RequestIDMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        requestID := r.Header.Get("X-Request-ID")
        if requestID == "" {
            requestID = uuid.New().String()
        }
        w.Header().Set("X-Request-ID", requestID)
        ctx := context.WithValue(r.Context(), RequestIDContextKey, requestID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}
Pattern Every piece of middleware follows the same shape: extract or generate a value, call context.WithValue, and pass the enriched context downstream via r.WithContext(ctx). Downstream code retrieves values with a typed getter function like GetTenantContext(ctx).

2.2   Context Key Types (Collision Prevention)

Each package defines its own private type for context keys. Even if two packages use the same string value (e.g. "request_id"), the distinct types prevent collisions because context.Value compares by (type, value) pair.

Using bare string keys would cause silent collisions across packages. Go's type system prevents this when each package wraps keys in its own named type.

Package Type Definition Key Constants
pkg/auth type TenantContextKey string
type contextKey string
"tenantContext", "claims"
pkg/middleware type ContextKey string "subdomain", "request_id"
pkg/shared/http type ContextKey string "request_id"
// pkg/auth/context.go
type TenantContextKey string     // for tenant context
type contextKey string            // for JWT claims (unexported!)

// pkg/middleware/tenant.go
type ContextKey string            // for subdomain & request ID

// pkg/shared/http/response.go
type ContextKey string            // for request ID in response meta
Key Insight Notice that both pkg/middleware and pkg/shared/http define ContextKey with the value "request_id". Because they are different named types in different packages, Go treats them as distinct keys — middleware.ContextKey("request_id")httpx.ContextKey("request_id"). The type system prevents accidental collisions even with identical string values.

2.3   context.Context — Timeout & Cancellation

Beyond value propagation, context.Context manages the lifecycle of operations through timeouts (WithTimeout) and cancellation signals (WithCancel).

Graceful Shutdown (10-second timeout)

Every service's main.go uses context.WithTimeout to allow the HTTP server 10 seconds to finish in-flight requests before forcing a shutdown.

// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit

slog.Info("Shutting down server...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

if err := server.Shutdown(shutdownCtx); err != nil {
    slog.Error("Server forced to shutdown", "error", err)
}
slog.Info("Server exited")

Kafka Consumer Lifecycle (context cancellation)

The Kafka consumer's Run(ctx) method blocks until the context is cancelled. Each subscription goroutine checks ctx.Done() in its consume loop, enabling clean shutdown of all consumers.

func (c *Consumer) Run(ctx context.Context) error {
    // ... start goroutine per subscription ...
    select {
    case <-ctx.Done():
        slog.Info("Consumer context cancelled, shutting down...")
    case err := <-errCh:
        slog.Error("Consumer error", "error", err)
        return err
    }
    c.wg.Wait()
    return ctx.Err()
}

func (c *Consumer) consumeLoop(ctx context.Context, sub *subscription) error {
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }
        msg, err := sub.reader.FetchMessage(ctx) // ctx cancellation propagates here
        // ... process message ...
    }
}

Context Propagation to Database (GORM WithContext)

The context flows all the way to the database layer via GORM's WithContext, ensuring that timeouts and cancellation signals propagate to SQL queries.

// Context flows to DB query — cancellation/timeout propagates to SQL
result := r.db.WithContext(ctx).
    Where("tenant_id = ?", tenantID).
    Find(&messages)
Context Flow Through the Stack:

  HTTP Request
      |
      ▼
  Middleware  ──→  context.WithValue(ctx, key, value)
      |
      ▼
  Handler     ──→  reads ctx values, passes ctx to service
      |
      ▼
  Service     ──→  business logic with ctx for cancellation
      |
      ▼
  Repository  ──→  db.WithContext(ctx).Find(...)   ← timeout/cancel propagates to SQL
      |
      ▼
  Database

2.4   Context for Distributed Tracing

When Service A publishes an event to Kafka and Service B consumes it, the trace context (trace ID, span ID) must travel with the event so both sides appear in the same distributed trace.

Without trace propagation across async boundaries, you get disconnected traces for producer and consumer — making it impossible to follow a request end-to-end in Jaeger/Grafana Tempo.

The pkg/eventbus/tracing.go file implements a headerCarrier that adapts event headers to the OpenTelemetry propagation.TextMapCarrier interface. This lets the OTel propagator inject/extract trace context through Kafka event headers.

// headerCarrier implements propagation.TextMapCarrier for event headers.
type headerCarrier map[string]string

func (c headerCarrier) Get(key string) string        { return c[key] }
func (c headerCarrier) Set(key, value string)        { c[key] = value }
func (c headerCarrier) Keys() []string              { /* ... */ }

// InjectTracing injects current trace context into the event headers.
func InjectTracing(ctx context.Context, event *Event) {
    if event.Headers == nil {
        event.Headers = make(map[string]string)
    }
    propagator := otel.GetTextMapPropagator()
    carrier := headerCarrier(event.Headers)
    propagator.Inject(ctx, carrier)

    span := trace.SpanFromContext(ctx)
    if span.SpanContext().IsValid() {
        event.TraceID = span.SpanContext().TraceID().String()
        event.SpanID  = span.SpanContext().SpanID().String()
    }
}

// ExtractTracing extracts trace context from event headers into a new context.
func ExtractTracing(ctx context.Context, event *Event) context.Context {
    if event.Headers == nil {
        return ctx
    }
    propagator := otel.GetTextMapPropagator()
    carrier := headerCarrier(event.Headers)
    return propagator.Extract(ctx, carrier)
}
Trace Propagation Across Services:

  auth-service (Producer)                    admin-service (Consumer)
  ┌──────────────────────┐                   ┌──────────────────────┐
  │  ctx has trace span  │                   │  ExtractTracing()    │
  │         │            │                   │         │            │
  │         ▼            │    Kafka Event     │         ▼            │
  │  InjectTracing()  ───┼──────────────────▶│  StartConsumerSpan() │
  │  span → headers      │  Headers carry:   │  parent = extracted  │
  │                      │  traceparent      │  span from headers   │
  │                      │  tracestate       │                      │
  └──────────────────────┘                   └──────────────────────┘

2.5   Interface Definitions (Domain Contracts)

Each service defines interfaces in its domain package that act as contracts between layers. The service layer depends on repository interfaces, and handlers depend on service interfaces — never on concrete types.

  • Testability — interfaces enable mock implementations for unit tests
  • Decoupling — the service layer doesn't know about GORM, HTTP, or Kafka
  • Dependency Inversion — high-level modules define contracts, low-level modules implement them

Step 1 — Define the contracts in the domain package

type UserService interface {
    Create(user *model.User, ctx context.Context) (*model.User, error)
    GetByID(id string, tenantID string) (*model.User, error)
    GetAll(tenantID string) (*[]model.User, error)
    DeleteById(id string, tenant_id string) (bool, error)
    UpdateOne(id string, tenantID string, payload *model.UserUpdate, ctx context.Context) (*model.User, error)
    ResetPassCode(email string) (bool, error)
    ChangePassword(id string, p *model.ChangePasswordRequest) (bool, error)
    GetApproversCount(tenantID string) (int64, error)
}

type UserRepository interface {
    Create(user *model.User) (*model.User, error)
    FindAll(tenantID string) (*[]model.User, error)
    FindByID(id string, tenantID string) (*model.User, error)
    DeleteOne(id string, tenant_id string) (bool, error)
    UpdateOne(id string, tenantID string, payload *model.UserUpdate, fields []string, ctx context.Context) (*model.User, error)
    CountApprovers(tenantID string) (int64, error)
}

Step 2 — Implement the service (depends on repository interface)

type userService struct {
    repo          domain.UserRepository   // depends on interface, not concrete type
    mail          *mail.EmailWorker
    feUrl         string
    kafkaProducer *kafkabus.Producer
    internalUrl   config.InternalUrl
}

func NewUserService(
    repo domain.UserRepository,
    mailWorker *mail.EmailWorker,
    feUrl string,
    kafkaProducer *kafkabus.Producer,
    internalUrl config.InternalUrl,
) domain.UserService {       // returns the interface type
    return &userService{
        repo:          repo,
        mail:          mailWorker,
        feUrl:         feUrl,
        kafkaProducer: kafkaProducer,
        internalUrl:   internalUrl,
    }
}

Step 3 — Implement the repository (satisfies interface implicitly)

type UserRepository struct {
    db *gorm.DB
}

func NewUserRepository(db *gorm.DB) domain.UserRepository {
    return &UserRepository{db: db}   // returns domain.UserRepository interface
}
Dependency Direction (Dependency Inversion):

  ┌───────────────────────────────────────────────────┐
  │                  domain package                   │
  │                                                   │
  │   UserService interface    UserRepository interface│
  │        ▲                          ▲               │
  │        │                          │               │
  └────────┼──────────────────────────┼───────────────┘
           │ implements               │ implements
  ┌────────┴────────┐       ┌────────┴────────┐
  │  service pkg    │       │  repository pkg │
  │                 │       │                 │
  │  userService    │──────▶│  UserRepository │
  │  (struct)       │ uses  │  (struct)       │
  └─────────────────┘       └─────────────────┘

2.6   Interface Composition (Embedding)

Go interfaces can embed other interfaces to build composed contracts. Struct embedding similarly combines concrete implementations.

Interface Embedding — ProducerConsumer = Producer + Consumer

type Producer interface {
    Publish(ctx context.Context, topic string, event *Event) error
    PublishBatch(ctx context.Context, topic string, events []*Event) error
    Close() error
}

type Consumer interface {
    Subscribe(topic, groupID string, handler Handler) error
    Run(ctx context.Context) error
    Close() error
}

// ProducerConsumer combines both interfaces via embedding.
type ProducerConsumer interface {
    Producer
    Consumer
}

Struct Embedding — Client embeds *Producer + *Consumer

// Client combines Producer and Consumer for services that need both.
type Client struct {
    *Producer        // embedded — Client "inherits" all Producer methods
    *Consumer        // embedded — Client "inherits" all Consumer methods
    config *Config
}

func NewClient(cfg *Config) *Client {
    return &Client{
        Producer: NewProducer(cfg),
        Consumer: NewConsumer(cfg),
        config:   cfg,
    }
}
Composition over Inheritance Go has no class inheritance. Instead, embedding lets you compose behaviors. The Client struct automatically satisfies ProducerConsumer because its embedded fields provide all the required methods.

2.7   Compile-Time Interface Checks

The var _ Interface = (*Struct)(nil) idiom asserts at compile time that a struct satisfies an interface.

Go's interfaces are satisfied implicitly — there's no implements keyword. This means if you forget to implement a method, you won't know until runtime (when you try to assign the struct to the interface type). These assertions catch missing methods at compile time.

// pkg/eventbus/kafka/consumer.go
var _ eventbus.Consumer    = (*Consumer)(nil)
var _ eventbus.HealthChecker = (*Consumer)(nil)

// pkg/eventbus/kafka/producer.go
var _ eventbus.Producer    = (*Producer)(nil)
var _ eventbus.HealthChecker = (*Producer)(nil)

// pkg/eventbus/kafka/client.go
var _ eventbus.ProducerConsumer = (*Client)(nil)

// pkg/eventbus/idempotency.go
var _ IdempotencyStore = (*InMemoryIdempotencyStore)(nil)
How It Works (*Consumer)(nil) creates a nil pointer of type *Consumer. Assigning it to eventbus.Consumer forces the compiler to verify that *Consumer has all the methods required by the interface. If any method is missing or has a wrong signature, compilation fails with a clear error message. The _ blank identifier discards the value — no runtime cost.

2.8   Small Interfaces (Go Philosophy)

Go favors small, focused interfaces — often just 1–3 methods. The codebase follows this philosophy consistently.

  • Easy to implement — fewer methods = lower barrier for new implementations
  • Easy to mock — tests only need to stub 1–3 methods
  • Composable — small interfaces can be combined via embedding (see 2.6)
  • Discoverable — a 1-method interface documents its purpose clearly

Single-method interface: KeyManager

// KeyManager has exactly one job: provide the signing key.
type KeyManager interface {
    GetSigningKey() []byte
}

Function type as interface: Handler

// Handler is a function type — the simplest possible "interface".
// Any function matching this signature can be used as a Handler.
type Handler func(ctx context.Context, event *Event) error

Focused interface: IdempotencyStore (3 methods)

// IdempotencyStore — just 3 methods for tracking processed events.
type IdempotencyStore interface {
    IsProcessed(ctx context.Context, key string) (bool, error)
    MarkProcessed(ctx context.Context, key string, ttl time.Duration) error
    Delete(ctx context.Context, key string) error
}

// HealthChecker — single-method for health checks.
type HealthChecker interface {
    Ping(ctx context.Context) error
}
Go Proverb “The bigger the interface, the weaker the abstraction.” — Rob Pike. A KeyManager with 1 method can be implemented by a static key, a KMS client, a Vault client, or a test double — all with one line of code.

2.9   Manual Dependency Injection

Dependencies are wired by hand in main.go using constructor functions. No DI framework (Wire, Dig, fx) is used.

  • Explicit — the wiring is visible and debuggable; no magic
  • No reflection — everything is type-checked at compile time
  • Sufficient — Go's interfaces + constructor functions provide clean DI without a framework
  • IDE-friendly — you can “Go to Definition” on any constructor to see what it needs
// ── Infrastructure ──────────────────────────────────
gormDB, err := db.NewGormDB(cfg.DatabaseURL)
kafkaProducer := kafkabus.NewProducer(cfg.Kafka)
emailWorker := mail.NewEmailWorker(cfg.Mail, 100)

// ── Generic service/repo/handler (hello world) ─────
repo := repository.New()
svc := service.New(repo)
h := handler.New(svc)

// ── User domain ─────────────────────────────────────
userRepo := repository.NewUserRepository(gormDB)
userService := service.NewUserService(userRepo, emailWorker, cfg.FeUrl, kafkaProducer, cfg.InternalUrl)
userHandler := handler.NewUserHandler(userService)

// ── Auth domain ─────────────────────────────────────
authRepo := repository.NewAuthRepository(gormDB, cfg.Secret, accessTokenExpiry, refreshTokenExpiry)
authService := service.NewAuthService(authRepo, cfg.Secret, cfg.InternalUrl)
authHandler := handler.NewAuthHandler(authService)

// ── Role domain ─────────────────────────────────────
roleRepo := repository.NewRoleRepository(gormDB)
roleService := service.NewRoleService(roleRepo)
roleHandler := handler.NewRoleHandler(roleService)

// ── Menu domain ─────────────────────────────────────
menuRepo := repository.NewMenuRepository(gormDB)
menuService := service.NewMenuService(menuRepo)
menuHandler := handler.NewMenuHandler(menuService)
Dependency Graph (auth-service):

  main.go
    |
    ├── gormDB ─────────────────────────┐
    ├── kafkaProducer ──────────────┐   │
    ├── emailWorker ────────────┐   │   │
    │                           │   │   │
    ├── userRepo(gormDB) ──────────────────────── domain.UserRepository
    │       │                   │   │
    │       ▼                   ▼   ▼
    ├── userService(userRepo, emailWorker, kafkaProducer, ...)
    │       │                                    ── domain.UserService
    │       ▼
    ├── userHandler(userService)
    │
    ├── authRepo(gormDB, ...) ─────────────────── domain.AuthRepository
    │       │
    │       ▼
    ├── authService(authRepo, ...)
    │       │                                    ── domain.AuthService
    │       ▼
    └── authHandler(authService)
The pattern is always the same NewXxxRepository(db)NewXxxService(repo, ...deps)NewXxxHandler(svc). Each constructor accepts interfaces and returns interfaces. The concrete wiring happens only in main.go, the composition root.

1Engage Multitenant Backend — Codebase Guide — Built for developers who need to understand the codebase quickly.