Wordloop Platform
Platform ServicesCore (Go)

Core Implementation Guide

Concrete Go patterns for trace-first logging, clean architecture, and error handling.

Core Implementation Guide (Go)

This guide translates WordLoop's overarching Engineering Principles into explicit, copy-pasteable Go code for the wordloop-core service.

1. Concrete Trace-First Development

We rely on OpenTelemetry for all observability. Every inbound request starts a trace, and every outbound request cascades it.

Initializing a Span

A new operation must start a span. If extracting from an HTTP Gin context, pass c.Request.Context().

import "go.opentelemetry.io/otel/trace"
 
func (s *TranscriptionService) Process(ctx context.Context, meetingID string) error {
	// 1. Start the span
	ctx, span := s.tracer.Start(ctx, "TranscriptionService.Process")
	
	// 2. Guarantee it closes
	defer span.End()
 
	// 3. Enrich the span with concrete, searchable attributes
	span.SetAttributes(attribute.String("meeting.id", meetingID))
	
	// ... logic
}

Passing Context

Context is King. Do not store context in structs. Pass it as the first parameter to every single Domain, Service, and Provider function. If you drop the context, you sever the distributed trace.

2. Concrete Error Handling

We use Go's errors.Is capabilities combined with purely defined "Sentinel Errors" to prevent database or HTTP leakage into our Domain logic.

Defining Sentinels

Define business rule errors in internal/core/domain/errors.go:

package domain
import "errors"
 
var ErrMeetingNotFound = errors.New("meeting not found")
var ErrUnauthorized = errors.New("unauthorized access")

Wrapping & Mapping Errors in Providers

An Adapter (Provider) catching a third-party or infrastructure error must wrap it into a Domain error before returning it to the Service.

package provider
 
import (
	"database/sql"
	"fmt"
	"wordloop-core/internal/core/domain"
)
 
func (r *PostgresMeetingStore) GetMeeting(ctx context.Context, id string) (*domain.Meeting, error) {
	var meeting domain.Meeting
	err := r.db.QueryRowContext(ctx, "SELECT ...").Scan(...)
	
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			// Map infrastructure error to Domain concept
			return nil, fmt.Errorf("provider execution failed: %w", domain.ErrMeetingNotFound)
		}
		return nil, fmt.Errorf("unexpected db error: %v", err)
	}
	return &meeting, nil
}

3. Concrete Dependency Injection

We use interface injection (Ports) to satisfy dependencies.

The Port (Defined by the Core)

The interface belongs in internal/core/gateway//service and is strictly defined using Domain language.

package gateway
 
import "wordloop-core/internal/core/domain"
 
type MeetingStore interface {
	GetMeeting(ctx context.Context, id string) (*domain.Meeting, error)
}

The Wiring (Entrypoint)

Constructor injection is used to assemble the pieces at startup without relying on globals.

// 1. Initialize the concrete Provider
dbProvider := provider.NewPostgresMeetingStore(sqlDB)
 
// 2. Inject it into the Service (which only knows the Gateway Interface)
meetingService := service.NewMeetingService(dbProvider)
 
// 3. Inject the Service into the inbound HTTP route
entrypoints.RegisterMeetingRoutes(router, meetingService)

4. Idiomatic Go & Standards

We do not aim to rewrite foundational guidance on writing excellent Go code. Instead, we adhere to established industry baselines and strictly map them to our internal engineering principles.

We expect all Wordloop Core engineers to intimately understand:

Below is concrete guidance on how overarching Go idioms manifest as system-enforced architectural invariants.

Accept Interfaces, Return Structs (Clean Architecture)

The Go Idiom: "Accept interfaces, return structs."
The Principle Connection: This idiom is the bedrock of Clean Architecture (Ports and Adapters). Gateways (Ports) define the interfaces. Services accept those interfaces. Providers return concrete struct representations.

// 1. The Gateway (Port) is an interface
type Store interface {
	Get(ctx context.Context, id string) (*domain.Meeting, error)
}
 
// 2. The Service accepts the interface
func NewService(store Store) *Service {
	return &Service{store: store}
}
 
// 3. The Provider (Adapter) returns the concrete struct
type PostgresStore struct { /* ... */ }
 
func NewPostgresStore(db *sql.DB) *PostgresStore {
	return &PostgresStore{db: db}
}

Goroutines and Context Loss (Trace-First)

The Go Idiom: "Don't leave goroutines hanging, and always pass context."
The Principle Connection: We practice Trace-First Observability. Executing a background goroutine without passing context severs the OpenTelemetry trace, blinding our dashboards to system behavior.

When spawning an asynchronous background task, use context.WithoutCancel (introduced in Go 1.21) or extract/inject the trace so the background span remains a child of the request trace—even if the HTTP client disconnects early.

func (s *Service) ProcessAsync(ctx context.Context) {
	// Prevent the goroutine from dying if the HTTP request closes early,
	// but preserve the Trace Context so the background task is observable.
	bgCtx := context.WithoutCancel(ctx)
	
	go func() {
		_, span := s.tracer.Start(bgCtx, "ProcessAsync.Background")
		defer span.End()
		
		// Execute asynchronous domain work...
	}()
}

Immutability in the Domain (Domain Purity)

The Go Idiom: Receiver types (Pointer vs Value semantics).
The Principle Connection: Our Domain layer must remain pure and free from unpredictable side effects.

When creating methods on Domain entities that calculate or evaluate state rather than modifying it, enforce immutability by exclusively using value receivers. This ensures core business rules remain deterministic, trivially unit-testable, and free from accidental pointer mutation.

package domain
 
// Meeting is our core domain entity.
type Meeting struct {
	DurationSeconds int
	Status          string
}
 
// CalculateCost utilizes a value receiver (m Meeting) instead of a pointer (*Meeting).
// This guarantees the calculation logic cannot accidentally mutate the Meeting's state.
func (m Meeting) CalculateCost(rate float64) float64 {
	return float64(m.DurationSeconds) * rate
}