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
| Concern | Choice | Why |
|---|---|---|
| HTTP | Echo v4 behind a Router/ReqCtx port | Swappable; the core never imports Echo |
| DB access | Bun behind a DataSource/DB port | Query builder, no raw SQL, Postgres and SQLite dialects |
| DI | Uber FX (fx.Module per feature) | Constructor injection, lifecycle hooks, composes a monolith from services |
| Validation | go-playground/validator v10, tags on the request DTO | Bound and validated by the router before the handler runs |
| Migrations | golang-migrate, per module, NNN_name.up/.down.sql | Owned by the module that owns the tables |
| Background jobs | asynq (Redis), with a no-op implementation for tests | Same interface locally and in production |
| Cache / rate limit | go-redis v9 + redis_rate | The rate limiter fails open |
| Observability | OpenTelemetry (OTLP) + Sentry; local Grafana LGTM | Standard env-var config; NOOP exporters when unset means zero overhead |
| Passwords | argon2id (alexedwards/argon2id), NFKC-normalised input | |
| IDs | rs/xid short IDs; UUID where an external contract needs one | Sortable, URL-safe, short |
| Money | shopspring/decimal | Never float64 |
| Docs → client | swaggo annotations → swagger.json → OpenAPI 3 → typed TS SDK + Zod | The client cannot drift from the server |
| Tests | httpexpect v2 + testify, real database | See 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 serviceEach 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 swaggerinternal/ 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.Modulecore/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 written | core.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 documentation | Outbound → 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 thing | Providers 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, loudly | With 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.
| Layer | Provides |
|---|---|
CoreModule | config, logger, DB manager, DataSource, tenant schema manager, encryptors, cache, sequencer, analytics, job queue, pub/sub, OTel — plus fx.Invoke(RunStartupMigrations) |
ServerModule | the Echo instance and the Router port over it |
features.Module | every feature |
LifecycleModule | the *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 main — defer 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
Repositoryininbound/. - Domain → HTTP mapping happens only here. Entity → DTO conversion is a
toXResponsefunction ininbound, 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
| Data | Connection | Examples |
|---|---|---|
| Tenant-scoped | ds.Tx(ctx) | users, orgs, sessions, content |
| Global | ds.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 missingTxRequired(ds)on the route group, or a repository using a global connection. Check middleware → handler → service → repository before touching a migration. ErrNoTransactionfromds.Tx(ctx)means the route group has noTxRequired. 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.
TransactionProviderStubreturning 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
DataSourceport; integration tests run against real Postgres with a temporary database per run. The dialect split is why migrations are kept per dialect underconfig/db/migrations/tenant/{postgres,sqlite}. - All HTTP tests sit in
inbound/handler_test.go, table-driven witht.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.