Architect
Standards
ReferenceNormative

Go services

Echo, Bun and Uber FX hidden behind our own ports, one fx.Module per feature, multi-tenant by schema — the go-to backend stack, with the wiring written out.

Verified 2026-08-14 · Against Go 1.25, Echo v4.15, Bun 1.2, Uber FX 1.24, validator v10, golang-migrate v4, asynq 0.26, OTel 1.43, swaggo 1.16. Code shapes taken from a 21-service monorepo running this stack.

Echo + Bun + Uber FX, all three hidden behind our own port interfaces. No framework type ever reaches a feature's core/. Features are core/ inbound/ outbound/ module.go, and module.go is the only place DI wiring lives. Multi-tenant by default: schema per tenant, the transaction opened by middleware, repositories taking it from the context.

The picks

ConcernChoiceWhy
HTTPEcho v4 behind a Router/ReqCtx portSwappable; the core never imports Echo
DB accessBun behind a DataSource/DB portQuery builder, no raw SQL, Postgres and SQLite dialects
DIUber FX (fx.Module per feature)Constructor injection, lifecycle hooks, composes a monolith from services
Validationgo-playground/validator v10, tags on the request DTOBound and validated by the router before the handler runs
Migrationsgolang-migrate, per module, NNN_name.up/.down.sqlOwned by the module that owns the tables
Background jobsasynq (Redis), with a no-op implementation for testsSame interface locally and in production
Cache / rate limitgo-redis v9 + redis_rateThe rate limiter fails open
ObservabilityOpenTelemetry (OTLP) + Sentry; local Grafana LGTMStandard env-var config; NOOP exporters when unset means zero overhead
Passwordsargon2id (alexedwards/argon2id), NFKC-normalised input
IDsrs/xid short IDs; UUID where an external contract needs oneSortable, URL-safe, short
Moneyshopspring/decimalNever float64
Docs → clientswaggo annotations → swagger.json → OpenAPI 3 → typed TS SDK + ZodThe client cannot drift from the server
Testshttpexpect v2 + testify, real databaseSee testing

Wrapped, never imported directly: Echo, Bun, Redis, log/slog, crypto/rand, golang.org/x/crypto, TOTP, base64 and hex codecs. Each gets exactly one owning package in pkg/, enforced by an architecture rule. That is what makes "no framework in core" a fact rather than an aspiration — and it means a library swap touches one package.

Repository layout

src/
  cmd/monolith/          the combined binary — every service in one process
  pkg/                   module "pkg"     — shared libraries, framework-agnostic
    core/                the ports: ReqCtx, Router, DataSource, StorageProvider
    integration/         the adapters that implement them: echo, bun, redis, …
    httpx/  util/  tests/
  platform/gateway/      module "gateway" — the edge
  shared/                module "shared"  — cross-service features + server bootstrap
  services/<name>/       module each      — one deployable domain service

Each service:

services/wallet/
  cmd/server/main.go             the FX composition root
  config/db/migrations/tenant/   per-dialect SQL owned by this service
  internal/features/<feature>/   core/ inbound/ outbound/ module.go
  internal/features/module.go    the one file listing every feature module
  docs/                          generated swagger

internal/ is doing real work. Go refuses an import of services/wallet/internal/... from anywhere outside services/wallet/. The feature boundary inside a service is a convention enforced by rules; the service boundary is enforced by the compiler.

The feature — four directories and one wiring file

features/payments/
  core/       entity.go  ports.go  errors.go  service.go   ← no framework imports
  inbound/    handler.go routes.go types.go   handler_test.go
  outbound/   repository.go  models.go  adapters.go        ← DB row structs live here
  module.go   the fx.Module

core/entity.go — the model refuses illegal transitions

The anti-anaemic-core test is whether the entity can answer a question about itself without a service:

type PaymentIntentStatus string

const (
	PaymentStatusPending   PaymentIntentStatus = "pending"
	PaymentStatusConfirmed PaymentIntentStatus = "confirmed"
	PaymentStatusCompleted PaymentIntentStatus = "completed"
	PaymentStatusFailed    PaymentIntentStatus = "failed"
	PaymentStatusExpired   PaymentIntentStatus = "expired"
)

func (p *PaymentIntent) IsTerminal() bool {
	return p.IsCompleted() || p.IsFailed() || p.IsExpired()
}

func (p *PaymentIntent) CanConfirm() bool {
	return p.IsPending() && !p.IsExpiredByTime()
}

A typed string constant plus predicates is Go's honest substitute for an enum — see typed closed sets. CanConfirm() is exercised by a plain unit test with no container.

core/ports.go — every dependency the core has, in its own vocabulary

// Persistence.
type PaymentIntentRepository interface {
	Create(ctx context.Context, intent *PaymentIntent) error
	FindByID(ctx context.Context, id string) (*PaymentIntent, error)
	FindByIdempotencyKey(ctx context.Context, key string) (*PaymentIntent, error)
	Update(ctx context.Context, intent *PaymentIntent) error
}

// Another feature, expressed as this feature needs it — not as that feature offers it.
type MerchantProvider interface {
	GetMerchant(ctx context.Context, merchantID string) (*MerchantInfo, error)
	ValidateMerchantCanAcceptPayment(ctx context.Context, merchantID string) error
}

type MerchantInfo struct {
	ID, Name, OwnerID string
	IsActive          bool
}

// An effect that does not exist yet.
type WebhookPublisher interface {
	PublishPaymentCompleted(ctx context.Context, intent *PaymentIntent) error
	PublishPaymentFailed(ctx context.Context, intent *PaymentIntent) error
}

MerchantInfo is the point. The port returns the four fields payments actually needs, not the merchants entity. The merchant model can grow twenty fields without payments recompiling, and payments cannot accidentally start depending on one.

core/service.go — constructor takes ports, nothing else

type Service struct {
	intentRepo       PaymentIntentRepository
	merchantProvider MerchantProvider
	txnProvider      TransactionProvider
	webhookPublisher WebhookPublisher
}

func NewService(
	intentRepo PaymentIntentRepository,
	merchantProvider MerchantProvider,
	txnProvider TransactionProvider,
	webhookPublisher WebhookPublisher,
) *Service {
	return &Service{intentRepo, merchantProvider, txnProvider, webhookPublisher}
}

Four interfaces in, one struct out. Nothing here knows what a database, an HTTP request or a queue is — which is what makes the whole service constructible in a test with four fakes and no infrastructure.

outbound/repository.go — the compile-time port assertion

type Repository struct {
	ds pcore.DataSource
}

var _ core.PaymentIntentRepository = (*Repository)(nil)   // ← breaks the build, not a test

func NewRepository(ds pcore.DataSource) core.PaymentIntentRepository {
	return &Repository{ds: ds}
}

func (r *Repository) FindByID(ctx context.Context, id string) (*core.PaymentIntent, error) {
	conn, err := r.ds.Tx(ctx)          // tenant-scoped; needs TxRequired on the route
	if err != nil {
		return nil, err
	}
	var model paymentIntentModel
	if err := conn.FindById(&model, id); err != nil {
		if pkgerrors.IsNotFound(err) {
			return nil, nil
		}
		return nil, err
	}
	return toEntity(&model), nil
}

Two habits worth copying:

  • var _ Port = (*Impl)(nil) in every adapter file. Go's implicit interface satisfaction means a drifted signature is otherwise only discovered at the wiring call, with an FX error at startup instead of a compile error at the edit.
  • The constructor returns the interface, not the struct. That is what lets FX bind the port to this adapter with no annotation — and it stops a caller reaching a method the port does not expose.

outbound/models.go holds the row struct, and it is the only place bun: tags exist:

type paymentIntentModel struct {
	bun.BaseModel `bun:"table:payment_intents"`

	ID         string    `bun:"id,pk"`
	MerchantID string    `bun:"merchant_id,notnull"`
	Status     string    `bun:"status,notnull"`
	CreatedAt  time.Time `bun:"created_at,notnull"`
}

func toModel(e *core.PaymentIntent) *paymentIntentModel { … }
func toEntity(m *paymentIntentModel) *core.PaymentIntent { … }

The entity has no tags, and the model is unexported. See separate DB models from domain entities.

Cross-feature calls are an adapter, never an import

This is the pattern that keeps a 20-feature service from becoming a graph. Payments needs merchant data. It does not import the merchants service into its core — it declares MerchantProvider and puts the translation in outbound/adapters.go:

type MerchantProviderAdapter struct {
	merchantService *merchantCore.Service
}

func NewMerchantProviderAdapter(s *merchantCore.Service) core.MerchantProvider {
	return &MerchantProviderAdapter{merchantService: s}
}

func (a *MerchantProviderAdapter) ValidateMerchantCanAcceptPayment(ctx context.Context, id string) error {
	merchant, err := a.merchantService.GetByID(ctx, id)
	if err != nil {
		return err
	}
	if !merchant.IsActive() {
		return core.ErrMerchantNotActive     // payments' error, not merchants'
	}
	return nil
}

The other feature is an outbound dependency like any database. The adapter is where the other feature's vocabulary is translated into this one's, including its errors — so a change over there produces a compile error in one file, not a behaviour change in a service.

The one thing that may cross core-to-core is a shared value type. Payments' entity imports merchantCore.QRCodeType because a QR type is the same concept in both. Behaviour crosses through a port; a value that is genuinely one concept does not need translating into a copy of itself. Be strict about which one you are looking at: if it has methods that do anything, it is behaviour.

A dependency that does not exist yet gets a stub

type WebhookPublisherStub struct{}

func NewWebhookPublisherStub() core.WebhookPublisher { return &WebhookPublisherStub{} }

func (s *WebhookPublisherStub) PublishPaymentCompleted(context.Context, *core.PaymentIntent) error {
	return nil
}

The core is written, tested and wired against the real port on day one. Replacing the stub later is one line in module.go and touches nothing else. This is what makes the port worth declaring before there is a second implementation — the alternative is a TODO in the service and a rewrite when the effect arrives.

Dependency injection with FX

One fx.Module per feature, and it is the only wiring file

package payments

var Module = fx.Module("payments",
	fx.Provide(
		outbound.NewRepository,                 // port ← adapter
		outbound.NewMerchantProviderAdapter,    // port ← another feature
		outbound.NewTransactionProviderStub,    // port ← stub
		outbound.NewWebhookPublisherStub,       // port ← stub
		core.NewService,                        // takes the four ports
		inbound.NewHandler,                     // takes the service
	),
	fx.Invoke(registerRoutes),
)

func registerRoutes(r pcore.Router, h *inbound.Handler, ds pcore.DataSource) {
	inbound.RegisterRoutes(r, h, ds)
}

Four things this buys, and they are the reason FX earns its place over hand-wiring:

The graph is derived, not writtencore.NewService asks for four interfaces; FX finds the four constructors that return them. Adding a fifth port is one line in fx.Provide, not a rewrite of a wiring function.
fx.Provide order is documentationOutbound → core → inbound reads as the dependency direction. FX itself does not care, which is exactly why the ordering is free to mean something.
fx.Invoke is the only eager thingProviders are lazy; nothing is constructed unless something needs it. Route registration is the side effect that pulls the feature into existence.
A missing binding fails at startup, loudlyWith the full graph and the type it could not resolve. Not a nil pointer on the first request.

fx.Invoke(registerRoutes) takes a local function rather than inbound.RegisterRoutes directly so the feature package owns its own wiring signature. inbound never imports FX.

The service aggregates its features in one file

// internal/features/module.go
var Module = fx.Options(
	wallets.Module,
	kyc.Module,
	merchants.Module,
	payments.Module,
	settlements.Module,
	// … and the cross-service features from the shared module
	categories.Module,
	tenants.Module,
	health.Module(config.Version),
)

fx.Options is a list, not a module — it flattens. One file that names every feature is the service's table of contents, and a feature that is not in it does not exist at runtime, which is a better failure than a feature that half-exists.

Note health.Module(config.Version) — a module can be a function returning fx.Option when it needs a value the graph does not carry.

The composition root is four layers

// cmd/server/main.go
func main() {
	opts := []fx.Option{
		server.CoreModule("wallet", "8097", &config.TenantMigrations), // infrastructure
		server.ServerModule,                                           // transport
		features.Module,                                               // domain
		server.LifecycleModule,                                        // start/stop
		service.FxLogger(),
	}
	fx.New(opts...).Run()
}

That is the whole main. Every service's is identical except the name, the port and the migrations.

LayerProvides
CoreModuleconfig, logger, DB manager, DataSource, tenant schema manager, encryptors, cache, sequencer, analytics, job queue, pub/sub, OTel — plus fx.Invoke(RunStartupMigrations)
ServerModulethe Echo instance and the Router port over it
features.Moduleevery feature
LifecycleModulethe *Server and its OnStart/OnStop hooks

WorkerCoreModule is CoreModule minus ServerModule — same infrastructure, no Echo, no router. A worker binary composes the same feature modules and simply never registers routes. That is only possible because routes are an fx.Invoke in the feature rather than a call in main.

Binding a concrete type to a port

Two annotations do all the work FX cannot infer:

// Provide a struct as an interface the graph asks for.
fx.Annotate(
	providePublicDBTransactionManager,
	fx.As(new(core.TransactionManager)),
),

// Two values of the same type in the graph — name them.
fx.Annotate(
	func() *embed.FS { return tenantMigrations },
	fx.ResultTags(`name:"tenantMigrations"`),
),
fx.Annotate(
	provideTenantSchemaManager,
	fx.ParamTags(``, `name:"tenantMigrations"`),
),

Prefer a constructor that returns the interface over fx.As. fx.As is for the cases you do not own — a third-party constructor, or a struct that legitimately satisfies two ports. Reaching for it by habit moves the port binding out of the adapter file, which is the one place someone reading the adapter would look for it.

Lifecycle hooks own start and stop

var LifecycleModule = fx.Options(
	fx.Provide(NewServer),
	fx.Invoke(registerLifecycle),
)

func registerLifecycle(lc fx.Lifecycle, srv *Server, ds core.DataSource) {
	lc.Append(fx.Hook{
		OnStart: func(ctx context.Context) error {
			srv.Setup()
			go srv.Start()
			return nil
		},
		OnStop: func(ctx context.Context) error {
			if err := srv.Shutdown(ctx); err != nil {
				return err
			}
			if ds != nil {
				return ds.Close()
			}
			return nil
		},
	})
}

OnStop runs in reverse dependency order, so the HTTP server drains before the datasource closes. That ordering is free and it is the reason to register a hook rather than call defer in maindefer gets the order right only by accident.

Handlers stay thin — 5 to 15 lines

The request DTO is the handler's second parameter. The router binds and validates it, so no handler calls Bind, and a bad payload never reaches the code you wrote:

func (h *Handler) Create(c pcore.ReqCtx, req CreateRequest) error {
    userID, err := c.RequireUserID()      // auto 401
    if err != nil { return err }
    result, err := h.service.Create(c.Unwrap(), req.ToInput(), userID)
    if err != nil {
        if errors.Is(err, core.ErrNotFound) { return c.NotFound("resource") }
        return err                        // unknown error → router maps to 500
    }
    return c.Created(toResponse(result))
}
  • Return raw errors for the unknown case. A handler that builds its own 500 duplicates the router's job and loses the central place where errors get logged and traced. Enforced: handlers may not call .InternalError(.
  • A handler holds services, never repositories. Enforced by a rule on the word Repository in inbound/.
  • Domain → HTTP mapping happens only here. Entity → DTO conversion is a toXResponse function in inbound, not a mapper package.

Routes declare their own middleware chain

func RegisterRoutes(r pcore.Router, h *Handler, ds pcore.DataSource) {
	g := r.Group("/payments",
		router.TenantRequired(),
		router.AuthRequired(),
		router.TxRequired(ds),
	)
	g.POST("/scan", h.ScanQR)
	g.POST("", h.CreatePaymentIntent)
	g.GET("/:id", h.GetPaymentIntent)
	g.POST("/:id/confirm", h.ConfirmPaymentIntent)
}

Available: TenantRequired(), AuthRequired(), AdminRequired(), TxRequired(ds), NewRateLimiter(n, duration). Admin routes are a separate group with AdminRequired().

Multi-tenancy — schema per tenant

DataConnectionExamples
Tenant-scopedds.Tx(ctx)users, orgs, sessions, content
Globalds.Conn(ctx)tenant registry, admin users, system config

Flow: middleware resolves the tenant → TxRequired(ds) opens a tenant-scoped transaction → the handler reads it via the request context → the repository calls ds.Tx(ctx).

For work outside a request — provisioning, jobs, tests — a TransactionManager runs the same thing explicitly:

err := s.txManager.RunInTransaction(ctx, func(txCtx context.Context) error { … })
  • One enriched request context, built by a single middleware, holding tenant, auth, admin and api-key facts. Every read helper checks it first. A second context key exists only for non-HTTP paths.
  • Tenant configuration lives in the database, encrypted (master key in the environment, per-tenant values in JSON columns): OAuth credentials, mail settings, feature flags. Only global configuration is env-var: database URL, master key, Redis URL.

Never register a tenant-dependent provider at startup. OAuth providers, mailers and storage connectors are resolved per request from the tenant's own record. Startup registration silently gives every tenant the first tenant's credentials — a data breach that looks like a caching bug.

This is the one place FX invites the mistake: a provider is a singleton by default, so anything tenant-shaped must be constructed per request behind a factory port, never fx.Provided.

  • File uploads go through presigned URLs issued by the platform. Entities store the object key, not a CDN URL, and services never import the storage implementation.

Traps

  • "relation X does not exist" is never a migration problem. The table exists in the tenant schema; the query ran against public. The cause is always tenant-context propagation — a missing TxRequired(ds) on the route group, or a repository using a global connection. Check middleware → handler → service → repository before touching a migration.
  • ErrNoTransaction from ds.Tx(ctx) means the route group has no TxRequired. Make the error message say so; the fix is one line and the symptom is otherwise unreadable.
  • Deleting an entity does not delete its files. A Delete(key) on the storage port is easy to write and easy to forget to call — wire it into the delete use case, or accept orphans.
  • The rate limiter must fail open. A Redis blip that returns 429 to every user is a worse outage than the abuse it was preventing.
  • A stub adapter that outlives its excuse. TransactionProviderStub returning a fake id is correct while the effect is unbuilt and a silent data defect the day someone assumes it works. Name stubs …Stub, and grep for them before a release.

Testing

  • No database mocks. Unit-level tests use in-memory SQLite through the same DataSource port; integration tests run against real Postgres with a temporary database per run. The dialect split is why migrations are kept per dialect under config/db/migrations/tenant/{postgres,sqlite}.
  • All HTTP tests sit in inbound/handler_test.go, table-driven with t.Run(), using an expressive DSL: e.POST(...).WithDomain(...).WithSessionToken(...).Expect().IsCreated().
  • Random fixture data (RandomString(), gofakeit). Hardcoded fixtures collide the moment tests run in parallel against a shared schema.
  • Coverage gate around 90%, enforced by the same target CI runs.

See testing for the container harness that keeps this fast.

Definition of done

make check && make test — lint, architecture checks, tests. See Go for how the architecture checks are built.

On this page