Go services
Echo, Bun and Uber FX hidden behind our own ports, one service and one fx.Module per feature, multi-tenant by schema, every route stating who may call it — the go-to backend stack, with the wiring written out.
Verified 2026-10-03 · Against Go 1.27, Echo v4.15.4, Bun v1.2.18, Uber FX v1.24.0, validator v10.30.3, golang-migrate v4.19.1, River v0.41.0, nats.go v1.54.0, OTel v1.46.0, swag v1.16.6, httpexpect v2.17.0, testify v1.12.1. Code shapes adapted from a production monorepo of about twenty services running this stack, names changed.
Echo + Bun + Uber FX, all three hidden behind our own port interfaces. No
framework type ever reaches a feature's core/. A feature is core/ inbound/ outbound/ module.go, its core is one service whose methods are its decisions,
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. Every route states who may call it, in one line.
The picks
| Concern | Choice | Why |
|---|---|---|
| HTTP | Echo v4 behind a Router/ReqCtx port | Swappable; the core never imports Echo |
| DB access | Bun (pgdialect, pgdriver) behind a DataSource port | Query builder, no raw SQL; PostgreSQL only |
| DI | Uber FX (fx.Module per feature) | Constructor injection, lifecycle hooks, test doubles by fx.Decorate |
| Validation | go-playground/validator v10, tags on the request DTO | Bound and validated by the router before the handler runs |
| Migrations | golang-migrate, per service, embedded, NNN_name.up/.down.sql | Owned by the service that owns the tables |
| Background jobs | River (Postgres-backed) behind a jobs.Queue port; an in-memory queue in tests | Jobs commit with the data; no second store to run |
| Messages between services | NATS JetStream behind a bus port, published from an outbox | A message leaves only if its transaction committed |
| Who may call a route | allow, one rule per route | One place decides access; a route with no rule refuses to start |
| Cache | go-redis v9 behind the one CacheProvider port | Nothing else imports go-redis |
| Observability | OpenTelemetry (OTLP) + Sentry; log/slog through one logging package | Standard env-var config; NOOP exporters when unset means zero overhead |
| Passwords | argon2id (alexedwards/argon2id), NFKC-normalised input | |
| Time and IDs | clock.Clock and ids.Generator (rs/xid v1.6.0) ports | A test stops the clock and counts the ids |
| Money | shopspring/decimal | Never float64 |
| Docs → client | swag annotations → swagger.json → OpenAPI 3 → typed TS SDK + Zod | The client cannot drift from the server |
| Tests | httpexpect v2 + testify, acceptance tests on a fake app | See testing |
Wrapped, never imported directly: Echo, Bun, go-redis, log/slog,
crypto/rand, golang.org/x/crypto, TOTP, base64 and hex codecs. Each gets
exactly one owning package in pkg/, enforced by the
checking script.
That is what makes "no framework in core" a fact rather than an aspiration — and
it means a library swap touches one package.
When not to: a library only one adapter uses needs no wrapper package — that adapter is already its one owner; give it a rule only when a second caller appears.
Repository layout
go.work
shared/foundation/ module "pkg" — framework-agnostic shared libraries
core/ the ports: ReqCtx, Router, DataSource, StorageProvider, EmailSender
integration/ the adapters that implement them: echo, bun, cache, email, …
jobs/ bus/ httpx/ util/ tests/
shared/features/ module "shared" — cross-service features, server bootstrap, allow, testutil
services/<name>/ module each — one deployable domain service
tooling/architecture/ the boundary checksEach service:
services/catalog/
cmd/server/main.go the FX composition root
config/main.go the version and the embedded migrations
config/db/migrations/tenant/postgres/ 001_baseline.up.sql, 001_baseline.down.sql, 002_…
internal/features/module.go the one file listing every feature module
internal/features/<feature>/ core/ inbound/ outbound/ module.go acceptance_test.go
internal/testutil/ this service's fake test app and helpers
service.go the service as one composable fx.Option
docs/ generated swaggerThe migrations travel inside the binary:
Adapted from a production service.
package config
import "embed"
var Version = "dev"
//go:embed all:db/migrations/tenant
var TenantMigrations embed.FS
- PostgreSQL only. One dialect, one migration chain per service, applied to every tenant schema at startup.
- A migration that has run in production is never edited; add the next number.
internal/ is doing real work. Go refuses an import of
services/catalog/internal/... from anywhere outside services/catalog/. The
feature boundary inside a service is a convention enforced by a script; the
service boundary is enforced by the compiler.
The feature — three directories and one wiring file
features/projects/
core/ entity.go ports.go errors.go service.go ← no framework imports
inbound/ handler.go routes.go types.go
outbound/ repository.go models.go project_checker.go ← DB row structs live here
memory/ repository.go ← the in-memory store tests use
module.go the fx.Module
acceptance_test.goWhen not to: a feature that only plugs in a shared kit has none of the three directories — see cross-feature calls.
core/entity.go — the model refuses illegal states
The anti-anaemic-core test is whether the
entity and its closed sets can answer a question about themselves without a
service. A closed set is a typed string with IsValid() and the predicates the
rules need — see typed closed sets for the excerpt. A
predicate like that is exercised by a plain unit test with no container.
core/ports.go — every dependency the core has, in its own vocabulary
Adapted from a production service.
// Repository defines the persistence interface for projects.
type Repository interface {
Create(ctx context.Context, project *Project) error
GetByID(ctx context.Context, workspaceID, id string) (*Project, error)
Update(ctx context.Context, project *Project) error
Delete(ctx context.Context, workspaceID, id string) error
List(ctx context.Context, workspaceID string, filter ListProjectsFilter, offset, limit int) ([]*Project, int64, error)
ExistsByName(ctx context.Context, workspaceID, name string, excludeID *string) (bool, error)
}
ExistsByName is there because the core needs to ask exactly that question — not
because the table has a name column. The port is the set of questions the rules
ask.
core/errors.go — refusals with codes
Each error is declared once with its code and kind, and the router answers it; see errors for the excerpt and the mapping.
One service per feature
A feature's core is one service. Each exported method is one decision; a pure lookup is a method that passes straight through to the port. The constructor takes ports, the clock and the id generator — nothing else:
Adapted from a production service.
type Service struct {
repo Repository
clock clock.Clock
ids ids.Generator
}
func NewService(repo Repository, clk clock.Clock, gen ids.Generator) *Service {
return &Service{repo: repo, clock: clk, ids: gen}
}
// Create validates and creates a new project.
func (s *Service) Create(ctx context.Context, workspaceID string, input *CreateProjectInput) (*Project, error) {
if err := s.validateCreate(input); err != nil {
return nil, err
}
name := strings.TrimSpace(input.Name)
exists, err := s.repo.ExistsByName(ctx, workspaceID, name, nil)
if err != nil {
return nil, err
}
if exists {
return nil, ErrDuplicateName
}
now := s.clock.Now()
project := &Project{
ID: s.ids.New("prj"), WorkspaceID: workspaceID, Name: name,
Country: strings.ToUpper(strings.TrimSpace(input.Country)),
StartDate: input.StartDate, EndDate: input.EndDate,
Status: StatusActive, CreatedAt: now, UpdatedAt: now,
}
if err := s.repo.Create(ctx, project); err != nil {
return nil, err
}
return project, nil
}
// GetByID is a lookup, not a decision: it passes straight through.
func (s *Service) GetByID(ctx context.Context, workspaceID, id string) (*Project, error) {
return s.repo.GetByID(ctx, workspaceID, id)
}
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 fakes and no infrastructure.
The method is the operation: its input type
lives in core, beside it, and the request type in inbound builds it.
When not to: do not split a feature into one struct per decision to mirror a Java use-case class. The methods share the same ports, and a struct per method multiplies constructors and FX bindings without adding a boundary.
outbound/ — the ports implemented
Adapted from a production service.
// Repository implements core.Repository on tenant-scoped transactions.
type Repository struct {
ds pcore.DataSource
}
func NewRepository(ds pcore.DataSource) core.Repository {
return &Repository{ds: ds}
}
func (r *Repository) GetByID(ctx context.Context, workspaceID, id string) (*core.Project, error) {
conn, err := r.ds.Tx(ctx) // tenant-scoped; needs TxRequired on the route
if err != nil {
return nil, err
}
var model projectModel
if err := conn.FindBy(&model, "id = ? AND workspace_id = ?", id, workspaceID); err != nil {
if db.IsNotFound(err) {
return nil, core.ErrProjectNotFound // the feature's not-found, never nil, nil
}
return nil, err
}
return toEntity(&model), nil
}
Two habits worth copying:
- The constructor returns the port, not the struct. The
returnis then a compile-time check that the adapter satisfies the port, FX binds the port to this adapter with no annotation, and no caller can reach a method the port does not expose. An adapter whose constructor returns the concrete type — the in-memory store below — asserts it instead:var _ core.Repository = (*Repository)(nil). - Not found is translated here, from the driver's error into the feature's.
outbound/models.go holds the row struct, and it is the only place bun: tags
exist:
Adapted from a production service.
package outbound
import (
"time"
"github.com/uptrace/bun"
"catalog/internal/features/projects/core"
)
type projectModel struct {
bun.BaseModel `bun:"table:projects"`
ID string `bun:"id,pk"`
WorkspaceID string `bun:"workspace_id,notnull"`
Name string `bun:"name,notnull"`
Country string `bun:"country,notnull"`
Status string `bun:"status,notnull"`
StartDate *time.Time `bun:"start_date,type:date"`
EndDate *time.Time `bun:"end_date,type:date"`
CreatedAt time.Time `bun:"created_at,notnull"`
UpdatedAt time.Time `bun:"updated_at,notnull"`
DeletedAt *time.Time `bun:"deleted_at,soft_delete,nullzero"`
}
func toEntity(m *projectModel) *core.Project {
return &core.Project{
ID: m.ID, WorkspaceID: m.WorkspaceID, Name: m.Name, Country: m.Country,
Status: core.Status(m.Status), StartDate: m.StartDate, EndDate: m.EndDate,
CreatedAt: m.CreatedAt.UTC(), UpdatedAt: m.UpdatedAt.UTC(),
}
}
func fromEntity(e *core.Project) *projectModel {
return &projectModel{
ID: e.ID, WorkspaceID: e.WorkspaceID, Name: e.Name, Country: e.Country,
Status: string(e.Status), StartDate: e.StartDate, EndDate: e.EndDate,
CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt,
}
}
The entity has no tags, and the model is unexported. See separate DB models from domain entities.
Cross-feature calls are a port, never an import into the core
This is the pattern that keeps a 20-feature service from becoming a graph. Tasks need to know a project exists. The tasks core does not import the projects feature — it declares the question in its own words:
Adapted from a production service.
// tasks/core/ports.go
// ProjectChecker answers whether a project exists.
type ProjectChecker interface {
ProjectExists(ctx context.Context, workspaceID, id string) (bool, error)
}The translation lives in the tasks feature's outbound/, which may depend on the
other feature's core — its port and its errors — and never on its adapters:
// tasks/outbound/project_checker.go
type ProjectCheckerAdapter struct {
repo projectscore.Repository
}
func NewProjectChecker(repo projectscore.Repository) core.ProjectChecker {
return &ProjectCheckerAdapter{repo: repo}
}
func (a *ProjectCheckerAdapter) ProjectExists(ctx context.Context, workspaceID, id string) (bool, error) {
_, err := a.repo.GetByID(ctx, workspaceID, id)
if errors.Is(err, projectscore.ErrProjectNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}The tasks service then refuses in its own vocabulary — if !exists { return nil, ErrProjectRequired } — so a change over there produces a compile error in one
adapter file, not a behaviour change in a service. FX hands the adapter
projectscore.Repository because the projects module already provides it.
The one thing that may cross core-to-core is a shared value type. If a type is the same concept in both features, a value that is genuinely one concept does not need translating into a copy of itself. Behaviour crosses through a port. Be strict about which one you are looking at: if it has methods that do anything, it is behaviour.
When not to: a feature that only configures a shared kit has no hexagon of its
own — no core/, no ports — just a module that plugs the kit in:
Adapted from a production service.
// features/popularsearches/module.go
var Module = fx.Options(
fx.Provide(func() core.ScopeCatalog { return catalog{} }),
sharedpopular.Module(sharedpopular.Config{
PruneJob: PruneJob,
PruneSubject: "catalog-popular-searches-prune",
}),
fx.Invoke(registerRoutes),
)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.
When not to: an effect nobody has asked for gets neither a port nor a stub.
Dependency injection with FX
One fx.Module per feature, and it is the only wiring file
Adapted from a production service.
package projects
import (
"go.uber.org/fx"
pcore "pkg/core"
"catalog/internal/features/projects/core"
"catalog/internal/features/projects/inbound"
"catalog/internal/features/projects/outbound"
)
var Module = fx.Module("projects",
fx.Provide(
outbound.NewRepository, // port ← adapter
core.NewService, // takes the port, the clock and the id generator
inbound.NewHandler, // takes the service
),
fx.Invoke(registerRoutes),
)
type registerRoutesParams struct {
fx.In
Router pcore.Router
Handler *inbound.Handler
DS pcore.DataSource
}
func registerRoutes(p registerRoutesParams) {
inbound.RegisterRoutes(p.Router, p.Handler, p.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 a port, a clock and an id generator; FX finds the constructors that return them. Adding a 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, and inbound never imports FX. An fx.In parameter struct keeps that
signature readable once it takes more than two things.
When DI is not used
FX wires singletons that live as long as the process. Everything else is plain Go:
| Thing | How it is made | Why not fx.Provide |
|---|---|---|
| Anything per tenant — a mailer, OAuth, storage credentials | A factory port in the graph, asked per request | A provider is a singleton; see the callout below |
| Request → input, entity → response | A method or function in inbound/types.go | A mapper is a function, not a dependency |
| In-memory stores for tests | fx.Decorate in the test, never a module's fx.Provide | Production must not be able to reach them |
| A value — a version, a feature's config | A module that is a function: health.Module(config.Version) | The graph carries types, not values |
Request mapping is a method on the request type:
Adapted from a production service.
type CreateProjectRequest struct {
WorkspaceID string `param:"workspaceId" json:"-" validate:"required"`
Name string `json:"name" validate:"required"`
Country string `json:"country" validate:"required"`
StartDate *string `json:"startDate,omitempty"`
EndDate *string `json:"endDate,omitempty"`
}
func (r *CreateProjectRequest) ToInput() (*core.CreateProjectInput, error) {
start, err := parseDate(r.StartDate)
if err != nil {
return nil, err
}
end, err := parseDate(r.EndDate)
if err != nil {
return nil, err
}
return &core.CreateProjectInput{Name: r.Name, Country: r.Country, StartDate: start, EndDate: end}, nil
}
The in-memory store says in its package comment why it exists and why it can never reach production:
Adapted from a production service.
// Package memory is the project table held in a map. It is the adapter the faked HTTP tests put in
// place of the PostgreSQL repository beside it, and it keeps that repository's rules: a project is
// only reachable through its own workspace, a deleted one is gone from every read, names are unique
// per workspace, and lists come back ordered by name.
//
// It lives beside the real adapter rather than in a _test file so any test in the service can
// import it, and it never reaches production because no module provides it.
package memory
The service aggregates its features in one file
Adapted from a production service.
// internal/features/module.go
var Module = fx.Options(
health.Module(config.Version),
tenants.Module,
settings.Module,
categories.Module,
search.Module(search.Config{Product: "catalog", ReconcileJob: "catalog.search.reconcile"}),
projects.Module,
tasks.Module,
popularsearches.Module,
)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.
Shared features (tenants, settings, search) are listed beside the service's
own; search.Module(...) is a module that is a function because it needs values
the graph does not carry.
The composition root is four layers
Adapted from a production service.
// Command server runs the catalog service.
package main
import (
"go.uber.org/fx"
"shared/server"
"catalog/config"
"catalog/internal/features"
)
func main() {
opts := []fx.Option{
server.CoreModule("catalog", &config.TenantMigrations), // infrastructure
server.ServerModule, // transport
features.Module, // domain
server.LifecycleModule, // start/stop
}
fx.New(opts...).Run()
}
That is the whole main. Every service's is identical except the name and the
migrations.
| Layer | Provides |
|---|---|
CoreModule | config, logger, DB manager, DataSource, tenant schema manager, transaction manager, encryptors, cache, job queue, message bus and its outbox, OTel, the clock and the id generator — 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:
Adapted from a production service.
// 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
Adapted from a production service.
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 {
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:
Adapted from a production service.
func (h *Handler) Create(c pcore.ReqCtx, req CreateProjectRequest) error {
input, err := req.ToInput()
if err != nil {
return c.BadRequest("invalid date format, expected YYYY-MM-DD")
}
project, err := h.service.Create(c.Context(), req.WorkspaceID, input)
if err != nil {
return err // coded errors answer their own status; anything else is a logged 500
}
return c.Created(toResponse(project))
}
- Return the error as it is. The router maps coded errors to their status and
answers anything else with a logged 500 (errors). A
handler that maps errors itself —
errors.Isthenc.NotFound(...)— duplicates the router and drifts from it. - The handler never asks who the caller is. That is the route's rule, below.
c.Context()is what the service receives: tenant, transaction and caller travel in it.- A handler holds services, never repositories. Enforced:
inboundmay not importoutboundor Bun, and theno-db-in-handlersAST rule refuses a query builder on any receiver. - Entity → DTO conversion is a
toResponsefunction ininbound, not a mapper package.
Who may call a route
Every route states its rule where it is registered, in one line. One shared
package, allow, decides; the route group carries only tenant and transaction
middleware:
Adapted from a production service.
package inbound
import (
pcore "pkg/core"
"pkg/integration/router"
"shared/allow"
)
func RegisterRoutes(r pcore.Router, h *Handler, ds pcore.DataSource) {
g := r.Group("/workspaces/:workspaceId/projects",
router.TenantRequired(),
router.TxRequired(ds),
)
g.POST("", allow.Staff(allow.Member).To(h.Create))
g.GET("", allow.Staff(allow.Viewer).To(h.List))
g.GET("/:id", allow.Staff(allow.Viewer).To(h.GetByID))
g.PUT("/:id", allow.Staff(allow.Member).To(h.Update))
g.DELETE("/:id", allow.Staff(allow.Member).To(h.Delete))
}
The package states the contract at its top:
Adapted from a production service.
// Package allow is the one place a service decides who may call a route.
//
// A route states its rule where it is registered, in one line, and the layer does the rest: the
// router resolves who the caller is and their live role on the tenant's own team, and the rule
// refuses with a coded error the router answers.
//
// g.GET("", allow.Staff(allow.Viewer).To(h.List))
// g.POST("", allow.Staff(allow.Member).To(h.Create))
//
// A feature never reads a role itself. A route that declares no rule stops its service from
// starting.
package allow
- The rules:
Staff(min)(rolesViewer < Member < Admin < Owner, read live, not from the token) in the example; production addsAnyone(),Session(),SignedIn(),Manager(resolve),OwnerOf(resolve),Service(names...)andAnyOf(rules...). - No rule, no start. Registering a route whose handler declares no rule panics at startup with the fix in the message — a forgotten rule is never an open route.
- Nothing else decides access. The access guard refuses hand-written guards
(
AuthRequired(,AdminRequired() and direct reads of the credentials in product code. - Each rule also states what every kind of caller should get, and one access test walks every declared route against it.
When not to: showing different rows to different callers is not refusing a
call — a feature reads allow.Person(ctx) to filter "mine", and asks
rule.Allows(ctx, in) for a yes or no; refusing is always the route's rule.
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 passes c.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.
The factory is the singleton; what it returns is per request:
Adapted from a production service.
// EmailSender sends emails.
type EmailSender interface {
Send(ctx context.Context, msg *EmailMessage) error
}
// EmailSenderFactory gives the sender of the tenant in ctx.
type EmailSenderFactory interface {
GetSender(ctx context.Context) (EmailSender, error)
}
// EmailProviderFactory is a pure function that creates a sender from a tenant's config.
// The feature layer fetches the config and calls it.
type EmailProviderFactory func(config *EmailConfig) (EmailSender, error)- 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. A stub 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. - A fake that is faked in one storage mode only. Email, storage, push, identity, jobs and the bus stay faked whether the test runs in memory or on PostgreSQL; they belong to the service's test wrapper, never to a test's stores.
Testing
Acceptance tests on a fake app, one acceptance_test.go per feature: the real
routes, a stopped clock, counting ids, in-memory stores that switch to real
PostgreSQL when TEST_DATABASE_URL is set, and exact asserts. The pattern and an
excerpt are on testing.
Each service wires it once:
Adapted from a production service.
// NewFakeTestApp boots this service in-process with every outside dependency faked, a clock stopped
// at FakeNow and counting ids. stores are one fx.Decorate per repository port the test replaces
// with an in-memory fake. TEST_DATABASE_URL decides whether they apply (unset or "memory") or the
// real repositories run on a temporary PostgreSQL database (a postgres:// URL).
func NewFakeTestApp(t *testing.T, stores ...fx.Option) *FakeTestApp {
t.Helper()
return sharedtest.NewFakeTestApp(t, serviceModules(), stores...)
}
- Coverage gate at 90%, enforced by the same target CI runs.
Definition of done
make check && make test — lint, integration-tag typecheck, architecture checks,
then the tests in memory and the same tests again on PostgreSQL. See
Go for how the
architecture checks are built.