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.