When writing backend services in Go, it is tempting to start with a flat structure where HTTP handlers directly execute database queries and format JSON responses. While this works for small scripts, it quickly becomes unmaintainable as services grow in complexity.
In this guide, we walk through building a production-shaped product-management microservice in Go using
Hexagonal Architecture (Ports and Adapters), a CQRS (Command Query Responsibility Segregation) split,
interfaces, gin, zerolog, google/uuid, mongodb,
and uber/fx for dependency injection. We also explore locking mechanisms, database transactions, and integration testing with Testcontainers.
By the end you will understand how to structure a Go service so its core business logic stays completely independent of the web framework, the database, the logger, or any other infrastructure choice.
Prerequisites: If you are new to Go or want to brush up on goroutines, channels, mutexes, context, or multi-module workspaces, read the companion guide: Go Fundamentals — The Complete Guide to Core Concepts, Concurrency, and Tooling.
A REST API (Representational State Transfer Application Programming Interface) is a way for applications to communicate over HTTP by following REST principles:
/products.GET
(read), POST (create), PUT/PATCH (update),
DELETE (remove).Hexagonal architecture — also called ports and adapters — is a way of organising code so the core business logic doesn't depend on the framework, the database, or any other external concern. Instead, the core defines ports (interfaces) that describe what it needs from the outside world, and adapters are the concrete things that plug into those ports.
Three layers, in order from inside to outside:
ProductCommandService, ProductQueryService). It depends only on the domain and on a set of port interfaces.The Inward Dependency Rule: Dependencies always point strictly inward: adapters depend on application, application depends on domain, and the domain depends on nothing. Swap MongoDB for Postgres or an in-memory map? Write a new outbound adapter — nothing in the application or domain layer changes.
The general rule is: Interfaces belong to the layer that depends on them, not the layer implementing them.
addProduct, listProducts, etc.).// application/ports/repository.go
package ports
import "cmd/product-management/domain" // or "yourapp/domain"
"ports" is a standard concept in Hexagonal Architecture, the package name itself is just a convention. A port is simply an interface that defines how the application core communicates with the outside world.
Used by things that call your application.
type ProductService interface {
AddProduct(cmd AddProductCommand) error
ListProducts() ([]Product, error)
}
Called by:
Many Java/C# Hexagonal architectures use inbound ports heavily. In Go, concrete application service structs are often called directly by inbound adapters, though interfaces can be introduced whenever multiple driving adapters or mocks require them.
Used by your application to call external systems.
type ProductRepository interface {
Save(product Product) error
FindAll() ([]Product, error)
}
Implemented by:
In Go, this is the most common use of ports.
Command Query Responsibility Segregation (CQRS) splits your code into a "write side" that
executes commands (e.g. AddProduct) and a "read side" that runs queries
(e.g. ListProducts).
The two sides can use different code paths and even different data stores. In our service, we structure use cases into ProductCommandService and ProductQueryService, allowing each side to evolve independently.
How you organize your application directory depends on the scale of the service:
application/
├── product/
│ ├── command_service.go
│ └── query_service.go
├── customer/
│ ├── command_service.go
│ └── query_service.go
application/
├── product_command_service.go
├── product_query_service.go
├── customer_command_service.go
├── customer_query_service.go
If ports.go (or ports/repository.go) contains the ProductRepository interface, and you're implementing it in an outbound adapter, then avoid names like ProductRepositoryImpl because that's more of a Java convention than a Go convention.
Better options in Go:
MongoProductRepository, InMemoryProductRepositoryProductRepository in package mongo or package inmemory (used as mongo.ProductRepository, inmemory.ProductRepository).If you have the following structure:
adapters/
├── in/
│ └── http/
│ ├── handler.go
│ └── product_handler.go
│
└── out/
└── mongo/
└── product_repository.go
application/
├── product_command_service.go
├── product_query_service.go
└── ports/
└── repository.go
domain/
├── errors.go
└── product.go
Here is how the adapters and wiring fit together:
// adapters/in/http
package http
type Handler struct {
commands *application.ProductCommandService
queries *application.ProductQueryService
logger zerolog.Logger
}
// adapters/out/mongo
package mongo
type ProductRepository struct {
coll *mongo.Collection // or db *sql.DB in SQL adapters
}
// Wiring in main.go:
func main() {
// create database connection
db := createMongoConnection()
// create repository adapter
productRepo := mongoadapter.NewProductRepository(db)
// create application services
productCommandService := app.NewProductCommandService(productRepo)
productQueryService := app.NewProductQueryService(productRepo)
// create http handler
productHandler := httpadapter.NewProductHandler(
productCommandService,
productQueryService,
)
// register routes
mux := http.NewServeMux()
mux.HandleFunc("GET /products", productHandler.ListProducts)
mux.HandleFunc("POST /products", productHandler.AddProduct)
log.Println("server running on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
product-management MicroserviceWe begin with the simplest possible main.go — just enough to serve a single endpoint:
package main
import (
"fmt"
"github.com/gin-gonic/gin"
)
func main() {
fmt.Println("Hello, World!")
server := gin.Default()
server.GET("/products", getProducts)
server.Run(":8080")
}
func getProducts(c *gin.Context) {
c.JSON(200, gin.H{"name": "Car Wipers"})
}
Let's unpack what each gin call does:
gin.Default() — creates an HTTP server pre-configured with a logger and a recovery handler (catches panics and returns 500).server.GET("/products", getProducts) — registers the handler for GET /products.*gin.Context — passed to every handler to read requests and write responses.gin.H — a convenience alias for map[string]any.That works, but everything is mixed together: routing, business logic, and data. We cannot test business rules in isolation or swap databases without modifying the HTTP handler.
product-management/
├── main.go ← Composition root: wires interfaces to adapters
├── go.mod
├── go.sum
│
├── domain/ ← Pure business rules, no I/O
│ ├── product.go ← Product entity + value types + validation
│ └── errors.go ← Domain sentinel errors
│
├── application/ ← Use cases (drives the domain)
│ ├── ports/
│ │ └── repository.go ← ProductRepository, IDGenerator ports
│ ├── product_command_service.go ← Write side of CQRS (AddProduct)
│ └── product_query_service.go ← Read side of CQRS (ListProducts)
│
├── adapters/ ← All I/O lives here
│ ├── in/
│ │ └── http/ ← Inbound HTTP adapter
│ │ ├── handler.go ← Router and Handler struct
│ │ └── product_handler.go ← Endpoint request/response logic
│ └── out/ ← Outbound adapters (databases)
│ ├── inmemory/
│ │ └── product_repository.go ← In-memory map repository
│ └── mongo/
│ └── product_repository.go ← MongoDB repository
│
└── tests/
└── product_api_test.go ← Integration tests with Testcontainers
The Product entity exposes a constructor that enforces invariants. Fields are unexported so an invalid Product cannot be created with a struct literal.
// domain/product.go
package product
import "strings"
// ID is the unique identifier of a Product.
type ID string
// Name is the product display name.
type Name string
// Price is the product price in minor currency units (e.g. cents) so we
// can keep the value as int64 and avoid floating-point precision issues.
type Price int64
// Product is the domain entity.
type Product struct {
id ID
name Name
price Price
}
// New builds a valid Product or returns an error explaining why the input is invalid.
func New(id ID, name Name, price Price) (Product, error) {
if strings.TrimSpace(string(name)) == "" {
return Product{}, ErrEmptyName
}
if price <= 0 {
return Product{}, ErrInvalidPrice
}
return Product{
id: id,
name: name,
price: price,
}, nil
}
func (p Product) ID() ID { return p.id }
func (p Product) Name() Name { return p.name }
func (p Product) Price() Price { return p.price }
// domain/errors.go
package product
import "errors"
var (
ErrEmptyName = errors.New("product name is empty")
ErrInvalidPrice = errors.New("product price must be positive")
ErrAlreadyExists = errors.New("product already exists")
)
Ports are interfaces that describe what the application needs from external systems.
// application/ports/repository.go
package ports
import (
"context"
product "cmd/product-management/domain"
)
// ProductRepository is the outbound port for persisting and loading products.
type ProductRepository interface {
Save(ctx context.Context, p product.Product) error
FindAll(ctx context.Context) ([]product.Product, error)
}
// IDGenerator abstracts ID creation so the domain stays deterministic and easy to test.
type IDGenerator interface {
NewID() product.ID
}
// application/product_command_service.go
package application
import (
"context"
"fmt"
"cmd/product-management/application/ports"
product "cmd/product-management/domain"
)
// ProductCommandService is the write side of CQRS.
type ProductCommandService struct {
repo ports.ProductRepository
ids ports.IDGenerator
}
func NewProductCommandService(repo ports.ProductRepository, ids ports.IDGenerator) *ProductCommandService {
return &ProductCommandService{repo: repo, ids: ids}
}
type AddProductCommand struct {
Name string
Price int64
}
type AddProductResult struct {
ID string
}
func (s *ProductCommandService) AddProduct(ctx context.Context, cmd AddProductCommand) (AddProductResult, error) {
id := s.ids.NewID()
p, err := product.New(id, product.Name(cmd.Name), product.Price(cmd.Price))
if err != nil {
return AddProductResult{}, fmt.Errorf("add product: %w", err)
}
if err := s.repo.Save(ctx, p); err != nil {
return AddProductResult{}, fmt.Errorf("save product: %w", err)
}
return AddProductResult{ID: string(id)}, nil
}
// application/product_query_service.go
package application
import (
"context"
"fmt"
"cmd/product-management/application/ports"
product "cmd/product-management/domain"
)
// ProductQueryService is the read side of CQRS.
type ProductQueryService struct {
repo ports.ProductRepository
}
func NewProductQueryService(repo ports.ProductRepository) *ProductQueryService {
return &ProductQueryService{repo: repo}
}
// ProductView is the DTO returned by the read side, independent of the domain entity.
type ProductView struct {
ID string `json:"id"`
Name string `json:"name"`
Price int64 `json:"price"`
}
func (s *ProductQueryService) ListProducts(ctx context.Context) ([]ProductView, error) {
products, err := s.repo.FindAll(ctx)
if err != nil {
return nil, fmt.Errorf("list products: %w", err)
}
views := make([]ProductView, 0, len(products))
for _, p := range products {
views = append(views, viewOf(p))
}
return views, nil
}
func viewOf(p product.Product) ProductView {
return ProductView{
ID: string(p.ID()),
Name: string(p.Name()),
Price: int64(p.Price()),
}
}
// adapters/out/inmemory/product_repository.go
package inmemory
import (
"context"
"sort"
"sync"
product "cmd/product-management/domain"
)
type ProductRepository struct {
mu sync.RWMutex
store map[product.ID]product.Product
}
func NewProductRepository() *ProductRepository {
return &ProductRepository{
store: make(map[product.ID]product.Product),
}
}
func (r *ProductRepository) Save(_ context.Context, p product.Product) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.store[p.ID()]; exists {
return product.ErrAlreadyExists
}
r.store[p.ID()] = p
return nil
}
func (r *ProductRepository) FindAll(_ context.Context) ([]product.Product, error) {
r.mu.RLock()
out := make([]product.Product, 0, len(r.store))
for _, p := range r.store {
out = append(out, p)
}
r.mu.RUnlock()
sort.Slice(out, func(i, j int) bool {
return out[i].Name() < out[j].Name()
})
return out, nil
}
// adapters/out/mongo/product_repository.go
package mongo
import (
"context"
"errors"
"fmt"
product "cmd/product-management/domain"
"go.mongodb.org/mongo-driver/bson"
mongodriver "go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
databaseName = "product_management"
collectionName = "products"
)
type productDocument struct {
ID string `bson:"_id"`
Name string `bson:"name"`
Price int64 `bson:"price"`
}
type ProductRepository struct {
coll *mongodriver.Collection
}
func NewProductRepository(client *mongodriver.Client) *ProductRepository {
return &ProductRepository{
coll: client.Database(databaseName).Collection(collectionName),
}
}
func (r *ProductRepository) Save(ctx context.Context, p product.Product) error {
doc := productDocument{
ID: string(p.ID()),
Name: string(p.Name()),
Price: int64(p.Price()),
}
_, err := r.coll.InsertOne(ctx, doc)
if err != nil {
if mongodriver.IsDuplicateKeyError(err) {
return product.ErrAlreadyExists
}
return fmt.Errorf("insert product: %w", err)
}
return nil
}
func (r *ProductRepository) FindAll(ctx context.Context) ([]product.Product, error) {
opts := options.Find().SetSort(bson.D{{Key: "name", Value: 1}})
cursor, err := r.coll.Find(ctx, bson.D{}, opts)
if err != nil {
return nil, fmt.Errorf("find products: %w", err)
}
defer cursor.Close(ctx)
var docs []productDocument
if err := cursor.All(ctx, &docs); err != nil {
return nil, fmt.Errorf("decode products: %w", err)
}
out := make([]product.Product, 0, len(docs))
for _, doc := range docs {
p, err := product.New(product.ID(doc.ID), product.Name(doc.Name), product.Price(doc.Price))
if err != nil {
return nil, fmt.Errorf("rebuild product %q: %w", doc.ID, err)
}
out = append(out, p)
}
return out, nil
}
func Connect(ctx context.Context, uri string) (*mongodriver.Client, error) {
client, err := mongodriver.Connect(ctx, options.Client().ApplyURI(uri))
if err != nil {
return nil, fmt.Errorf("connect mongo: %w", err)
}
if err := client.Ping(ctx, nil); err != nil {
_ = client.Disconnect(ctx)
return nil, fmt.Errorf("ping mongo: %w", err)
}
return client, nil
}
func Disconnect(ctx context.Context, client *mongodriver.Client) error {
if client == nil {
return nil
}
err := client.Disconnect(ctx)
if err != nil && !errors.Is(err, mongodriver.ErrClientDisconnected) {
return err
}
return nil
}
The HTTP adapter maps incoming HTTP requests to use-case commands/queries and formats JSON responses.
// adapters/in/http/handler.go
package http
import (
"net/http"
"cmd/product-management/application"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
)
type Handler struct {
commands *application.ProductCommandService
queries *application.ProductQueryService
logger zerolog.Logger
}
func NewHandler(
commands *application.ProductCommandService,
queries *application.ProductQueryService,
logger zerolog.Logger,
) *Handler {
return &Handler{
commands: commands,
queries: queries,
logger: logger,
}
}
func (h *Handler) Routes() http.Handler {
engine := gin.New()
engine.Use(gin.Recovery())
engine.HandleMethodNotAllowed = true
engine.GET("/products", h.listProducts)
engine.POST("/products", h.addProduct)
return engine
}
// adapters/in/http/product_handler.go
package http
import (
"errors"
"net/http"
"cmd/product-management/application"
product "cmd/product-management/domain"
"github.com/gin-gonic/gin"
)
func (h *Handler) listProducts(c *gin.Context) {
views, err := h.queries.ListProducts(c.Request.Context())
if err != nil {
h.logger.Error().Err(err).Msg("list products failed")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list products"})
return
}
c.JSON(http.StatusOK, gin.H{"products": views})
}
type addProductRequest struct {
Name string `json:"name"`
Price int64 `json:"price"`
}
func (h *Handler) addProduct(c *gin.Context) {
var req addProductRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
return
}
res, err := h.commands.AddProduct(c.Request.Context(), application.AddProductCommand{
Name: req.Name,
Price: req.Price,
})
if err != nil {
switch {
case errors.Is(err, product.ErrEmptyName),
errors.Is(err, product.ErrInvalidPrice),
errors.Is(err, product.ErrAlreadyExists):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
h.logger.Error().Err(err).Msg("add product failed")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add product"})
}
return
}
c.JSON(http.StatusCreated, gin.H{"id": res.ID})
}
main.go is the composition root: the only place that knows about both abstract ports and concrete adapters. Here is how manual wiring looks:
// main.go (Manual wiring example)
func main() {
// 1. Create database connection
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := mongo.Connect(ctx, "mongodb://localhost:27017")
if err != nil {
log.Fatal(err)
}
// 2. Create repository adapter (satisfies ports.ProductRepository)
productRepo := mongoadapter.NewProductRepository(client)
idGen := uuidIDs{}
// 3. Create application use-case services
productCommandService := application.NewProductCommandService(productRepo, idGen)
productQueryService := application.NewProductQueryService(productRepo)
// 4. Create HTTP handler adapter
logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
handler := httpadapter.NewHandler(productCommandService, productQueryService, logger)
// 5. Start HTTP server
log.Println("server running on :8080")
log.Fatal(http.ListenAndServe(":8080", handler.Routes()))
}
go build ./...
go run .
# in another terminal:
# Add a product
curl -X POST -H 'Content-Type: application/json' \
-d '{"name":"Car Wipers","price":1999}' \
http://localhost:8080/products
# => 201 {"id":"7d8d17cf-909c-4173-bd90-4e777acc60aa"}
# List products
curl http://localhost:8080/products
# => 200 {"products":[{"id":"...","name":"Car Wipers","price":1999}]}
go.mod" & Toolchain Mechanicsgo.mod
Before adding external packages, the entire go.mod was just three lines:
module cmd/product-management
go 1.22
Why? Because Go's standard library provides full solutions for routing, logging, ID generation, and synchronization out of the box:
| Capability | Stdlib equivalent | Ecosystem library |
|---|---|---|
| HTTP Routing | net/http ServeMux (Go 1.22+) |
github.com/gin-gonic/gin |
| Structured Logging | log/slog (Go 1.21+) |
github.com/rs/zerolog |
| ID Generation | crypto/rand + encoding/hex |
github.com/google/uuid |
| Concurrency | sync.RWMutex |
In-memory storage |
Since Go 1.21, Go supports automatic toolchain switching. If go.mod specifies a version newer than your installed Go compiler, Go will automatically download the required toolchain.
You can control this behavior using GOTOOLCHAIN:
GOTOOLCHAIN=local go build ./...
| Situation | What to do |
|---|---|
| Building in a sandboxed / offline / CI environment | GOTOOLCHAIN=local |
| You want strict reproducibility over which Go compiles code | GOTOOLCHAIN=local |
| Adding a dep that requires a newer Go than yours | Pin to an older version of the dep, or upgrade local Go |
uber/fxuber/fx?
uber/fx is a dependency injection framework for Go. You hand it a bag of constructors via
fx.Provide(...); fx introspects parameter and return types, constructs the dependency graph in topological order, manages lifecycles (OnStart, OnStop hooks), and handles graceful shutdown.
fx.Provide(...) — registers constructor functions into the graph.fx.Invoke(...) — kicks off execution (e.g. starting the HTTP server).fx.Lifecycle — used to register OnStart and OnStop hooks.fx.Shutdowner — allows any component to trigger graceful shutdown programmatically.appOptions / newApp / main PatternThis structure enables clean dynamic adapter selection (switching between MongoDB and In-Memory via environment variables) and allows integration tests to inject test overrides:
// main.go (Complete with uber/fx & dynamic adapter selection)
package main
import (
"context"
"errors"
"net/http"
"os"
"strings"
"time"
httpadapter "cmd/product-management/adapters/in/http"
"cmd/product-management/adapters/out/inmemory"
mongoadapter "cmd/product-management/adapters/out/mongo"
"cmd/product-management/application"
"cmd/product-management/application/ports"
product "cmd/product-management/domain"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/fx"
"go.uber.org/fx/fxevent"
)
const (
httpAddr = ":8080"
shutdownTimeout = 5 * time.Second
mongoConnectTimeout = 10 * time.Second
defaultMongoURI = "mongodb://localhost:27017"
)
type uuidIDs struct{}
func (uuidIDs) NewID() product.ID { return product.ID(uuid.NewString()) }
func newLogger() zerolog.Logger {
return zerolog.New(os.Stdout).Level(zerolog.InfoLevel).With().Timestamp().Logger()
}
func asIDGenerator() ports.IDGenerator { return uuidIDs{} }
func productRepositoryKind() string {
switch strings.ToLower(os.Getenv("PRODUCT_REPOSITORY")) {
case "memory":
return "memory"
default:
return "mongo"
}
}
func mongoURI() string {
if uri := os.Getenv("MONGO_URI"); uri != "" {
return uri
}
return defaultMongoURI
}
func newMongoClient(lc fx.Lifecycle, logger zerolog.Logger) (*mongo.Client, error) {
connectCtx, cancel := context.WithTimeout(context.Background(), mongoConnectTimeout)
defer cancel()
client, err := mongoadapter.Connect(connectCtx, mongoURI())
if err != nil {
return nil, err
}
lc.Append(fx.Hook{
OnStop: func(ctx context.Context) error {
if err := mongoadapter.Disconnect(ctx, client); err != nil {
return err
}
logger.Info().Msg("mongo client disconnected")
return nil
},
})
logger.Info().Str("uri", mongoURI()).Msg("mongo client connected")
return client, nil
}
func newProductRepository(
lc fx.Lifecycle,
logger zerolog.Logger,
) (ports.ProductRepository, error) {
switch productRepositoryKind() {
case "memory":
logger.Info().Msg("using in-memory product repository")
return inmemory.NewProductRepository(), nil
default:
client, err := newMongoClient(lc, logger)
if err != nil {
return nil, err
}
return mongoadapter.NewProductRepository(client), nil
}
}
func newHTTPHandler(h *httpadapter.Handler) http.Handler { return h.Routes() }
func newHTTPServer(handler http.Handler) *http.Server {
return &http.Server{
Addr: httpAddr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
}
func registerHTTPServer(
lc fx.Lifecycle,
logger zerolog.Logger,
server *http.Server,
shutdowner fx.Shutdowner,
) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
go func() {
logger.Info().Str("addr", server.Addr).Msg("http server starting")
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error().Err(err).Msg("http server error")
_ = shutdowner.Shutdown(fx.ExitCode(1))
}
}()
return nil
},
OnStop: func(ctx context.Context) error {
shutdownCtx, cancel := context.WithTimeout(ctx, shutdownTimeout)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
return err
}
logger.Info().Msg("http server stopped")
return nil
},
})
}
func appOptions(overrides ...fx.Option) []fx.Option {
gin.SetMode(gin.ReleaseMode)
opts := []fx.Option{
fx.WithLogger(func() fxevent.Logger { return fxevent.NopLogger }),
fx.Provide(
newLogger,
asIDGenerator,
newProductRepository,
application.NewProductCommandService,
application.NewProductQueryService,
httpadapter.NewHandler,
newHTTPHandler,
newHTTPServer,
),
fx.Invoke(registerHTTPServer),
}
return append(opts, overrides...)
}
func newApp(overrides ...fx.Option) *fx.App { return fx.New(appOptions(overrides...)...) }
func main() { newApp().Run() }
The database sets a lock to the record during field/read so that another transaction cannot update the same record.
Common in Oracle, MySQL:
-- SQL Pessimistic Locking
SELECT * FROM products WHERE id = 1 FOR UPDATE;
Detect concurrent updates to the same document via a version field. If the version field is different, then it is a conflict.
What it does: Makes several reads/writes commit or abort all together.
session.WithTransaction() from the mongo driver library is used for transaction management.
WithTransaction Works:Flow was roughly:
client.StartSession()).session.WithTransaction.SessionContext) into repo methods like Save(txCtx, p).WriteConflict)
When Transactions Matter:
A transaction would only matter if one use case did multiple coordinated writes, e.g.: when adding a new product to the inventory, then the transaction must contain all these operations — insert product, insert audit log, update inventory. Then WithTransaction() in MongoDB ensures all three succeed or none do.
If you have only one operation (e.g. insert product), then a transaction is usually unnecessary overhead.
This is used for concurrent updates to same document. Mongo does not give you this automatically. You add it:
// read product, version = 3
db.products.updateOne(
{ _id: "abc", version: 3 },
{ $set: { price: 2000, version: 4 } }
)
// if matchedCount == 0 → someone else updated first → retry or 409 Conflict
Pattern:
{ _id, version: old }That is your optimistic locking, separate from Mongo transactions.
go get github.com/testcontainers/testcontainers-go github.com/testcontainers/testcontainers-go/modules/mongodb github.com/stretchr/testify
testify is required for go test assertions
t.Cleanup(fn)
t.Cleanup(fn) registers a function to run after the test finishes, whether it passes or fails. It’s like defer, but tied to the test lifecycle instead of the function scope. They run in reverse order (LIFO):
t.Cleanup(func() {
require.NoError(t, mongoC.Terminate(ctx))
})
// ...
t.Cleanup(func() {
require.NoError(t, mongoadapter.Disconnect(ctx, client))
})
// ...
t.Cleanup(srv.Close)
t.Helper()
t.Helper() = provides cleaner failure line numbers in test output.
t.Run()
t.Run() defines a subtest inside a parent test function:
func TestProductAPI(t *testing.T) {
env := setupTestEnv(t)
t.Run("add product", func(t *testing.T) {
// subtest 1
})
t.Run("list products", func(t *testing.T) {
// subtest 2
})
}
Why use this? Mongo/container startup runs once in the parent; subtests reuse env. That’s why your suite is faster than two separate top-level tests each calling setupTestEnv.
You can run one subtest:
go test -v ./tests/... -run 'TestProductAPI/list_products'
Subtests can call t.Parallel() if they’re independent. Yours share one DB, so they run sequentially.
// tests/product_api_test.go
package integration_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
httpadapter "cmd/product-management/adapters/in/http"
mongoadapter "cmd/product-management/adapters/out/mongo"
"cmd/product-management/application"
"cmd/product-management/application/ports"
product "cmd/product-management/domain"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/rs/zerolog"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
)
type uuidIDs struct{}
func (uuidIDs) NewID() product.ID { return product.ID(uuid.NewString()) }
type testEnv struct {
srv *httptest.Server
}
func setupTestEnv(t *testing.T) *testEnv {
t.Helper()
gin.SetMode(gin.TestMode)
ctx := context.Background()
// 1. Start MongoDB container
mongoC, err := mongodb.Run(ctx, "mongo:7")
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, mongoC.Terminate(ctx))
})
uri, err := mongoC.ConnectionString(ctx)
require.NoError(t, err)
// 2. Connect client
connectCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
client, err := mongoadapter.Connect(connectCtx, uri)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, mongoadapter.Disconnect(ctx, client))
})
// 3. Assemble application graph
repo := mongoadapter.NewProductRepository(client)
var idGen ports.IDGenerator = uuidIDs{}
commands := application.NewProductCommandService(repo, idGen)
queries := application.NewProductQueryService(repo)
handler := httpadapter.NewHandler(commands, queries, zerolog.Nop())
// 4. Start ephemeral test HTTP server
srv := httptest.NewServer(handler.Routes())
t.Cleanup(srv.Close)
return &testEnv{srv: srv}
}
func (e *testEnv) addProduct(t *testing.T, name string, price int64) string {
t.Helper()
resp, err := http.Post(
e.srv.URL+"/products",
"application/json",
bytes.NewBufferString(fmt.Sprintf(`{"name":%q,"price":%d}`, name, price)),
)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
var body map[string]string
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.NotEmpty(t, body["id"])
return body["id"]
}
func (e *testEnv) listProducts(t *testing.T) []application.ProductView {
t.Helper()
resp, err := http.Get(e.srv.URL + "/products")
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var body struct {
Products []application.ProductView `json:"products"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
return body.Products
}
func TestProductAPI(t *testing.T) {
env := setupTestEnv(t)
t.Run("add product", func(t *testing.T) {
id := env.addProduct(t, "Widget", 1299)
require.NotEmpty(t, id)
})
t.Run("list products", func(t *testing.T) {
id := env.addProduct(t, "Gadget", 999)
products := env.listProducts(t)
var got *application.ProductView
for i := range products {
if products[i].ID == id {
got = &products[i]
break
}
}
require.NotNil(t, got, "added product not found in list response")
require.Equal(t, "Gadget", got.Name)
require.Equal(t, int64(999), got.Price)
})
}
ports.ProductRepository) describing what it needs from external systems.ProductRepositoryImpl; prefer package-scoped names like mongo.ProductRepository or inmemory.ProductRepository.uber/fx: Provides declarative dependency graph building, lifecycle management, and dynamic repository switching.SELECT FOR UPDATE), Application-level Optimistic Locking (version filters), and Multi-document Transactions (session.WithTransaction()).t.Cleanup(), t.Helper(), and t.Run() for fast, reliable real-world integration testing.Want to learn more about Go language mechanics, goroutines, channels, memory models, and workspace dependency resolution?
👉 Check out: Go Fundamentals — The Complete Guide to Core Concepts, Concurrency, and Tooling.